From 6f68348c4aa11c6c863c5ed5075362cd10af547f Mon Sep 17 00:00:00 2001 From: Andrew Curioso Date: Sat, 7 Feb 2026 23:36:38 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=20Change=20error=20archit?= =?UTF-8?q?ecture=20to=20interface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/util/withnil.go | 10 +- internal/util/withnil_test.go | 12 +- pkg/errors/collection.go | 246 +++++++++----- pkg/errors/collection_internal_test.go | 71 ++++ pkg/errors/collection_test.go | 306 +++++++++--------- pkg/errors/for_path_as_test.go | 66 ++-- pkg/errors/range_error_test.go | 22 ++ pkg/errors/validation_error.go | 146 +++++---- pkg/rules/any.go | 34 +- pkg/rules/bool.go | 42 +-- pkg/rules/bool_private_test.go | 2 +- pkg/rules/bool_test.go | 19 +- pkg/rules/constant.go | 12 +- pkg/rules/float.go | 30 +- pkg/rules/float_test.go | 6 +- pkg/rules/inerface.go | 45 +-- pkg/rules/int.go | 42 +-- pkg/rules/int_test.go | 10 +- pkg/rules/interface_test.go | 8 +- pkg/rules/knownKeys.go | 14 +- pkg/rules/net/conflict_type_private_test.go | 6 +- pkg/rules/net/domain.go | 62 ++-- pkg/rules/net/email.go | 77 ++--- pkg/rules/net/email_test.go | 2 +- pkg/rules/net/ip.go | 65 ++-- pkg/rules/net/query.go | 46 +-- pkg/rules/net/query_private_test.go | 24 +- pkg/rules/net/query_test.go | 32 +- pkg/rules/net/rule_domain_suffix.go | 6 +- pkg/rules/net/rule_ip_cidr.go | 6 +- pkg/rules/net/rule_ip_public_private.go | 11 +- pkg/rules/net/rule_ip_range.go | 6 +- pkg/rules/net/rule_ip_subnet_mask.go | 6 +- pkg/rules/net/rule_ip_version.go | 11 +- pkg/rules/net/uri.go | 163 ++++------ pkg/rules/net/uri_test.go | 30 +- pkg/rules/number_rule_max.go | 6 +- pkg/rules/number_rule_maxexclusive.go | 6 +- pkg/rules/number_rule_min.go | 6 +- pkg/rules/number_rule_minexclusive.go | 6 +- pkg/rules/number_rule_values.go | 10 +- pkg/rules/object.go | 95 ++---- pkg/rules/object_interface_test.go | 2 +- pkg/rules/object_test.go | 120 +++---- pkg/rules/rule.go | 6 +- pkg/rules/rule_maxlen.go | 6 +- pkg/rules/rule_maxlen_test.go | 4 +- pkg/rules/rule_minlen.go | 6 +- pkg/rules/rule_minlen_test.go | 4 +- pkg/rules/ruleset.go | 17 +- pkg/rules/slice.go | 126 +++----- pkg/rules/slice_interface_test.go | 2 +- pkg/rules/slice_test.go | 92 +++--- pkg/rules/string.go | 34 +- pkg/rules/string_rule_max.go | 6 +- pkg/rules/string_rule_max_test.go | 2 +- pkg/rules/string_rule_maxexclusive.go | 6 +- pkg/rules/string_rule_min.go | 6 +- pkg/rules/string_rule_min_test.go | 2 +- pkg/rules/string_rule_minexclusive.go | 6 +- pkg/rules/string_rule_regex.go | 6 +- pkg/rules/string_rule_values.go | 10 +- pkg/rules/string_test.go | 14 +- pkg/rules/time/duration.go | 48 +-- pkg/rules/time/duration_private_test.go | 2 +- pkg/rules/time/rule_duration_max.go | 6 +- pkg/rules/time/rule_duration_maxexclusive.go | 6 +- pkg/rules/time/rule_duration_min.go | 6 +- pkg/rules/time/rule_duration_minexclusive.go | 6 +- pkg/rules/time/rule_duration_rounding_test.go | 28 +- pkg/rules/time/rule_max.go | 6 +- pkg/rules/time/rule_maxdiff.go | 6 +- pkg/rules/time/rule_maxexclusive.go | 6 +- pkg/rules/time/rule_min.go | 6 +- pkg/rules/time/rule_mindiff.go | 6 +- pkg/rules/time/rule_minexclusive.go | 6 +- pkg/rules/time/time.go | 36 +-- pkg/rules/time/time_private_test.go | 2 +- pkg/rules/wrap_any.go | 47 +-- pkg/testhelpers/mock.go | 28 +- pkg/testhelpers/mock_test.go | 5 +- pkg/testhelpers/util.go | 35 +- pkg/testhelpers/util_test.go | 90 +++++- pkg/testhelpers/witherrorconfig.go | 62 ++-- pkg/testhelpers/witherrorconfig_test.go | 12 +- pkg/testhelpers/withnil.go | 4 +- pkg/testhelpers/withnil_test.go | 92 +++--- 87 files changed, 1357 insertions(+), 1496 deletions(-) create mode 100644 pkg/errors/collection_internal_test.go create mode 100644 pkg/errors/range_error_test.go diff --git a/internal/util/withnil.go b/internal/util/withnil.go index c574511..04ae437 100644 --- a/internal/util/withnil.go +++ b/internal/util/withnil.go @@ -10,7 +10,7 @@ import ( // TrySetNilIfAllowed attempts to set the output to nil if withNil is true and input is nil. // It returns true if nil was successfully set (and the caller should return), false if normal processing should continue, // and an error if there was a problem setting nil or if nil is not allowed. -func TrySetNilIfAllowed(ctx context.Context, withNil bool, input, output any) (handled bool, err errors.ValidationErrorCollection) { +func TrySetNilIfAllowed(ctx context.Context, withNil bool, input, output any) (handled bool, err errors.ValidationError) { // If input is not nil, continue with normal processing if input != nil { return false, nil @@ -19,17 +19,13 @@ func TrySetNilIfAllowed(ctx context.Context, withNil bool, input, output any) (h // Input is nil - check if nil is allowed if !withNil { // Nil is not allowed, return error - return true, errors.Collection(errors.Error( - errors.CodeNull, ctx, - )) + return true, errors.Error(errors.CodeNull, ctx) } // Nil is allowed - ensure output is a pointer outputVal := reflect.ValueOf(output) if outputVal.Kind() != reflect.Ptr || outputVal.IsNil() { - return false, errors.Collection(errors.Errorf( - errors.CodeInternal, ctx, "internal error", "output must be a non-nil pointer", - )) + return false, errors.Errorf(errors.CodeInternal, ctx, "internal error", "output must be a non-nil pointer") } // Get the element the pointer points to diff --git a/internal/util/withnil_test.go b/internal/util/withnil_test.go index 8c73923..f69debb 100644 --- a/internal/util/withnil_test.go +++ b/internal/util/withnil_test.go @@ -27,8 +27,8 @@ func TestTrySetNilIfAllowed(t *testing.T) { } if err == nil { t.Error("Expected error when nil is not allowed") - } else if err.First().Code() != errors.CodeNull { - t.Errorf("Expected error code to be CodeNull, got: %s", err.First().Code()) + } else if err.Code() != errors.CodeNull { + t.Errorf("Expected error code to be CodeNull, got: %s", err.Code()) } // Test case 3: input is nil, withNil is true, output is not a pointer - should return false with CodeInternal error @@ -39,8 +39,8 @@ func TestTrySetNilIfAllowed(t *testing.T) { } if err == nil { t.Error("Expected error when output is not a pointer") - } else if err.First().Code() != errors.CodeInternal { - t.Errorf("Expected error code to be CodeInternal, got: %s", err.First().Code()) + } else if err.Code() != errors.CodeInternal { + t.Errorf("Expected error code to be CodeInternal, got: %s", err.Code()) } // Test case 4: input is nil, withNil is true, output is nil pointer - should return false with CodeInternal error @@ -51,8 +51,8 @@ func TestTrySetNilIfAllowed(t *testing.T) { } if err == nil { t.Error("Expected error when output pointer is nil") - } else if err.First().Code() != errors.CodeInternal { - t.Errorf("Expected error code to be CodeInternal, got: %s", err.First().Code()) + } else if err.Code() != errors.CodeInternal { + t.Errorf("Expected error code to be CodeInternal, got: %s", err.Code()) } // Test case 5: input is nil, withNil is true, output points to nil-able type (pointer) - should set to nil diff --git a/pkg/errors/collection.go b/pkg/errors/collection.go index 28e1dc1..09a83e8 100644 --- a/pkg/errors/collection.go +++ b/pkg/errors/collection.go @@ -2,111 +2,104 @@ package errors import "fmt" -// ValidationErrorCollection implements a standard Error interface and also ValidationErrorCollection interface -// while preserving the validation data. -type ValidationErrorCollection []ValidationError +// multiError holds multiple ValidationErrors and implements ValidationError by +// delegating Code(), Path(), etc. to the first error. Unwrap() returns the list. +type multiError struct { + errs []ValidationError +} -// Collection creates a new ValidationErrorCollection from one or more ValidationError values. -func Collection(errs ...ValidationError) ValidationErrorCollection { - var arr []ValidationError +var _ ValidationError = (*multiError)(nil) - if errs == nil { - arr = make([]ValidationError, 0) - } else { - arr = errs[:] +// Unwrap returns the list of wrapped errors for use with errors.Is and errors.As. Nil receiver returns nil. +func (e *multiError) Unwrap() []error { + if e == nil { + return nil } - - return ValidationErrorCollection(arr) + out := make([]error, len(e.errs)) + for i := range e.errs { + out[i] = e.errs[i] + } + return out } -// Error implements the standard Error interface to return a string. -// -// Error returns only the first error if there is more than one, along with the total count. -// Error loses contextual data, so use the ValidationError object when possible. -// -// If there is more than one error, which error is displayed is not guaranteed to be deterministic. -// -// An empty collection should never be returned from a function. Return nil instead. Error panics if called on an empty collection. -func (collection ValidationErrorCollection) Error() string { - if len(collection) > 1 { - return fmt.Sprintf("%s (and %d more)", []ValidationError(collection)[0].Error(), len(collection)-1) +// Error returns the long-form message; for multiple errors, returns the first message plus a count. +func (e *multiError) Error() string { + if len(e.errs) == 0 { + return "(no validation errors)" } - - if len(collection) > 0 { - return []ValidationError(collection)[0].Error() + if len(e.errs) > 1 { + return fmt.Sprintf("%s (and %d more)", e.errs[0].Error(), len(e.errs)-1) } - - panic("Empty collection") + return e.errs[0].Error() } -// Unwrap implements the wrapped Error interface to return an array of errors. -// This enables support for errors.Is and errors.As from the standard library. -// -// Returns an empty slice for empty collections. An empty collection should never be returned from a function. Return nil instead. -func (collection ValidationErrorCollection) Unwrap() []error { - errs := make([]error, len(collection)) - for i := range collection { - errs[i] = collection[i] +// Code returns the first error's code, or empty if there are no errors. +func (e *multiError) Code() ErrorCode { + if len(e.errs) == 0 { + return "" } - return errs + return e.errs[0].Code() } -// First returns only the first error. -// If there is more than one error, the error returned is not guaranteed to be deterministic. -func (collection ValidationErrorCollection) First() ValidationError { - if len(collection) == 0 { - return nil +// Path returns the first error's path, or empty if there are no errors. +func (e *multiError) Path() string { + if len(e.errs) == 0 { + return "" } - - return collection[0] + return e.errs[0].Path() } -// For returns a new collection containing only errors for a specific path. -func (collection ValidationErrorCollection) For(path string) ValidationErrorCollection { - if len(collection) == 0 { - return nil +// PathAs returns the first error's path using the given serializer. +func (e *multiError) PathAs(serializer PathSerializer) string { + if len(e.errs) == 0 { + return "" } + return e.errs[0].PathAs(serializer) +} - var filteredErrors []ValidationError - for _, err := range collection { - if err.Path() == path { - filteredErrors = append(filteredErrors, err) - } +// ShortError returns the first error's short description. +func (e *multiError) ShortError() string { + if len(e.errs) == 0 { + return "" } + return e.errs[0].ShortError() +} - if len(filteredErrors) == 0 { - return nil +// DocsURI returns the first error's documentation URI. +func (e *multiError) DocsURI() string { + if len(e.errs) == 0 { + return "" } - - return Collection(filteredErrors...) + return e.errs[0].DocsURI() } -// ForPathAs returns a new collection containing only errors for a specific path -// using the provided serializer to compare paths. -func (collection ValidationErrorCollection) ForPathAs(path string, serializer PathSerializer) ValidationErrorCollection { - if len(collection) == 0 { - return nil +// TraceURI returns the first error's trace URI. +func (e *multiError) TraceURI() string { + if len(e.errs) == 0 { + return "" } + return e.errs[0].TraceURI() +} - var filteredErrors []ValidationError - for _, err := range collection { - if err.PathAs(serializer) == path { - filteredErrors = append(filteredErrors, err) - } +// Meta returns the first error's metadata. +func (e *multiError) Meta() map[string]any { + if len(e.errs) == 0 { + return nil } + return e.errs[0].Meta() +} - if len(filteredErrors) == 0 { +// Params returns the first error's format params. +func (e *multiError) Params() []any { + if len(e.errs) == 0 { return nil } - - return Collection(filteredErrors...) + return e.errs[0].Params() } -// Internal returns true if any error in the collection is an internal error. -// Internal errors are the most general classification and take precedence. -// Returns false for empty collections. -func (collection ValidationErrorCollection) Internal() bool { - for _, err := range collection { +// Internal returns true if any wrapped error is internal. +func (e *multiError) Internal() bool { + for _, err := range e.errs { if err.Internal() { return true } @@ -114,15 +107,12 @@ func (collection ValidationErrorCollection) Internal() bool { return false } -// Permission returns true if the most general error classification is permission. -// Permission errors are more general than validation errors but less general than internal errors. -// Returns true if any error is a permission error and no errors are internal. -// Returns false for empty collections. -func (collection ValidationErrorCollection) Permission() bool { - if collection.Internal() { +// Permission returns true if any wrapped error is a permission error and none are internal. +func (e *multiError) Permission() bool { + if e.Internal() { return false } - for _, err := range collection { + for _, err := range e.errs { if err.Permission() { return true } @@ -130,13 +120,91 @@ func (collection ValidationErrorCollection) Permission() bool { return false } -// Validation returns true if all errors are validation errors. -// Validation errors are the most specific classification. -// Returns true only if no errors are internal or permission errors. -// Returns false for empty collections. -func (collection ValidationErrorCollection) Validation() bool { - if len(collection) == 0 { +// Validation returns true if there is at least one error and none are internal or permission. +func (e *multiError) Validation() bool { + if len(e.errs) == 0 { return false } - return !collection.Internal() && !collection.Permission() + return !e.Internal() && !e.Permission() +} + +// Join combines zero or more errors into a single ValidationError. +// Nil entries are skipped. Non-ValidationError entries are skipped. +// If an argument is a ValidationError that wraps multiple errors (Unwrap() non-empty), it is flattened so those errors are merged in. Single errors are added as-is. +// Returns nil for zero ValidationErrors, the single error unchanged for one, or a multiError for two or more. +func Join(errs ...error) ValidationError { + var verrs []ValidationError + for _, e := range errs { + if e == nil { + continue + } + ve, ok := e.(ValidationError) + if !ok { + continue + } + u := ve.Unwrap() + if len(u) == 0 { + verrs = append(verrs, ve) + } else { + for _, sub := range u { + if v, ok := sub.(ValidationError); ok { + verrs = append(verrs, v) + } + } + } + } + switch len(verrs) { + case 0: + return nil + case 1: + return verrs[0] + default: + return &multiError{errs: verrs} + } +} + +// Unwrap returns the list of errors from err for iteration or len. Nil err returns nil. +// For a single error (err.Unwrap() is nil), returns []error{err}. Otherwise returns err.Unwrap(). +func Unwrap(err ValidationError) []error { + if err == nil { + return nil + } + u := err.Unwrap() + if len(u) == 0 { + return []error{err} + } + return u +} + +// For returns a ValidationError containing only the wrapped errors whose Path() equals path. +// If err is nil or no errors match, returns nil. If exactly one matches, returns that error; +// if multiple match, returns Join of the matches. +func For(err ValidationError, path string) ValidationError { + unwrapped := Unwrap(err) + if len(unwrapped) == 0 { + return nil + } + var matched []error + for _, e := range unwrapped { + if ve, ok := e.(ValidationError); ok && ve.Path() == path { + matched = append(matched, ve) + } + } + return Join(matched...) +} + +// ForPathAs is like For but compares paths using the given serializer (e.g. to filter by JSON Pointer or JSONPath). +// Use when the path string is in a different format than the default. +func ForPathAs(err ValidationError, path string, serializer PathSerializer) ValidationError { + unwrapped := Unwrap(err) + if len(unwrapped) == 0 { + return nil + } + var matched []error + for _, e := range unwrapped { + if ve, ok := e.(ValidationError); ok && ve.PathAs(serializer) == path { + matched = append(matched, ve) + } + } + return Join(matched...) } diff --git a/pkg/errors/collection_internal_test.go b/pkg/errors/collection_internal_test.go new file mode 100644 index 0000000..96b5e42 --- /dev/null +++ b/pkg/errors/collection_internal_test.go @@ -0,0 +1,71 @@ +package errors + +import ( + "context" + "testing" +) + +// TestMultiErrorEmptyErrList covers multiError methods when errs is empty (defensive branches). +// Join() never returns an empty multiError, but the type supports it. +func TestMultiErrorEmptyErrList(t *testing.T) { + e := &multiError{errs: nil} + if out := e.Unwrap(); len(out) != 0 { + t.Errorf("Unwrap() on empty multiError should have length 0, got %v", out) + } + if msg := e.Error(); msg != "(no validation errors)" { + t.Errorf("Error() = %q", msg) + } + if c := e.Code(); c != "" { + t.Errorf("Code() = %q", c) + } + if p := e.Path(); p != "" { + t.Errorf("Path() = %q", p) + } + if p := e.PathAs(DefaultPathSerializer{}); p != "" { + t.Errorf("PathAs() = %q", p) + } + if s := e.ShortError(); s != "" { + t.Errorf("ShortError() = %q", s) + } + if u := e.DocsURI(); u != "" { + t.Errorf("DocsURI() = %q", u) + } + if u := e.TraceURI(); u != "" { + t.Errorf("TraceURI() = %q", u) + } + if m := e.Meta(); m != nil { + t.Errorf("Meta() = %v", m) + } + if params := e.Params(); params != nil { + t.Errorf("Params() = %v", params) + } + if e.Validation() { + t.Error("Validation() on empty multiError should be false") + } +} + +// TestMultiErrorSingleErr covers multiError.Error() when len(errs)==1 (single message, no "and N more"). +func TestMultiErrorSingleErr(t *testing.T) { + ctx := context.Background() + single := Errorf(CodeMin, ctx, "short", "long %d", 10) + e := &multiError{errs: []ValidationError{single}} + if msg := e.Error(); msg != single.Error() { + t.Errorf("Error() = %q, want %q", msg, single.Error()) + } +} + +// TestMultiErrorNilReceiver covers multiError.Unwrap() with nil receiver. +func TestMultiErrorNilReceiver(t *testing.T) { + var e *multiError + if out := e.Unwrap(); out != nil { + t.Errorf("Unwrap() on nil multiError should return nil, got %v", out) + } +} + +// TestSingleErrorNilReceiverUnwrap covers singleError.Unwrap() with nil receiver. +func TestSingleErrorNilReceiverUnwrap(t *testing.T) { + var ve ValidationError = (*singleError)(nil) + if out := ve.Unwrap(); out != nil { + t.Errorf("Unwrap() on nil singleError should return nil, got %v", out) + } +} diff --git a/pkg/errors/collection_test.go b/pkg/errors/collection_test.go index c05dc97..55c6179 100644 --- a/pkg/errors/collection_test.go +++ b/pkg/errors/collection_test.go @@ -15,14 +15,14 @@ import ( func TestCollectionWrapper(t *testing.T) { ctx := context.Background() - err := pkgerrors.Collection( + err := pkgerrors.Join( pkgerrors.Errorf(pkgerrors.CodeMax, ctx, "above maximum", "error1"), pkgerrors.Errorf(pkgerrors.CodeMax, ctx, "above maximum", "error2"), ) if err == nil { t.Errorf("Expected error to not be nil") - } else if s := len(err); s != 2 { + } else if s := len(pkgerrors.Unwrap(err)); s != 2 { t.Errorf("Expected error to have size %d, got %d", 2, s) } } @@ -34,22 +34,16 @@ func TestCollectionAsSlice(t *testing.T) { err1 := pkgerrors.Errorf(pkgerrors.CodeMax, ctx, "above maximum", "error1") err2 := pkgerrors.Errorf(pkgerrors.CodeMax, ctx, "above maximum", "error2") - colErr := pkgerrors.Collection( - err1, - err2, - ) + colErr := pkgerrors.Join(err1, err2) if colErr == nil { t.Fatal("Expected error to not be nil") - } else if s := len(colErr); s != 2 { - t.Fatalf("Expected error to have size %d, got %d", 2, s) } - - if l := len(colErr); l != 2 { + all := pkgerrors.Unwrap(colErr) + if l := len(all); l != 2 { t.Fatalf("Expected error to have length %d, got %d", 2, l) } - - if !((colErr[0] == err1 && colErr[1] == err2) || (colErr[0] == err2 && colErr[1] == err1)) { + if !((all[0] == err1 && all[1] == err2) || (all[0] == err2 && all[1] == err1)) { t.Errorf("Expected both errors to be in collection") } } @@ -62,19 +56,12 @@ func TestCollectionUnwrap(t *testing.T) { err1 := pkgerrors.Errorf(pkgerrors.CodeMax, ctx, "above maximum", "error1") err2 := pkgerrors.Errorf(pkgerrors.CodeMax, ctx, "above maximum", "error2") - colErr := pkgerrors.Collection( - err1, - err2, - ) + colErr := pkgerrors.Join(err1, err2) if colErr == nil { t.Fatal("Expected error to not be nil") - } else if s := len(colErr); s != 2 { - t.Fatalf("Expected error to have size %d, got %d", 2, s) } - - all := colErr.Unwrap() - + all := pkgerrors.Unwrap(colErr) if l := len(all); l != 2 { t.Fatalf("Expected error to have length %d, got %d", 2, l) } @@ -84,60 +71,118 @@ func TestCollectionUnwrap(t *testing.T) { } } -// TestCollectionLen tests that len(collection) returns the number of errors. -func TestCollectionLen(t *testing.T) { +// TestJoinFlattensMultiError tests that Join flattens a multiError (Unwrap() non-empty) into the result. +func TestJoinFlattensMultiError(t *testing.T) { ctx := context.Background() + err1 := pkgerrors.Errorf(pkgerrors.CodeMax, ctx, "above maximum", "error1") + err2 := pkgerrors.Errorf(pkgerrors.CodeMax, ctx, "above maximum", "error2") + err3 := pkgerrors.Errorf(pkgerrors.CodeMax, ctx, "above maximum", "error3") + + inner := pkgerrors.Join(err1, err2) + outer := pkgerrors.Join(inner, err3) + all := pkgerrors.Unwrap(outer) + if len(all) != 3 { + t.Errorf("Expected Join to flatten to 3 errors, got %d", len(all)) + } +} +// TestJoinSkipsNilsAndNonValidationErrors tests that Join skips nil entries and non-ValidationError values. +func TestJoinSkipsNilsAndNonValidationErrors(t *testing.T) { + ctx := context.Background() err1 := pkgerrors.Errorf(pkgerrors.CodeMax, ctx, "above maximum", "error1") err2 := pkgerrors.Errorf(pkgerrors.CodeMax, ctx, "above maximum", "error2") - colErr := pkgerrors.Collection(err1) + col := pkgerrors.Join(nil, err1, nil, err2) + if col == nil { + t.Fatal("Join(nil, err1, nil, err2) should not be nil") + } + if n := len(pkgerrors.Unwrap(col)); n != 2 { + t.Errorf("Expected 2 errors, got %d", n) + } - if s := len(colErr); s != 1 { - t.Errorf("Expected size to be 1, got: %d", s) + plainErr := errors.New("plain") + col = pkgerrors.Join(err1, plainErr, err2) + if col == nil { + t.Fatal("Join with plain error should not be nil") } + if n := len(pkgerrors.Unwrap(col)); n != 2 { + t.Errorf("Expected 2 ValidationErrors (plain skipped), got %d", n) + } +} + +// TestCollectionLen tests that Unwrap() returns the correct number of errors. +func TestCollectionLen(t *testing.T) { + ctx := context.Background() + + err1 := pkgerrors.Errorf(pkgerrors.CodeMax, ctx, "above maximum", "error1") + err2 := pkgerrors.Errorf(pkgerrors.CodeMax, ctx, "above maximum", "error2") - colErr = append(colErr, err2) + colErr := pkgerrors.Join(err1) + if s := len(pkgerrors.Unwrap(colErr)); s != 1 { + t.Errorf("Expected size to be 1, got: %d", s) + } - if s := len(colErr); s != 2 { + colErr = pkgerrors.Join(err1, err2) + if s := len(pkgerrors.Unwrap(colErr)); s != 2 { t.Errorf("Expected size to be 2, got: %d", s) } } -// TestCollectionFirst tests: -// - Returns the first error from a collection -// - Returns one of the errors when multiple errors exist -func TestCollectionFirst(t *testing.T) { +// TestCollectionCodeWhenMultiple tests that Code() returns the first error's code when multiple errors exist. +func TestCollectionCodeWhenMultiple(t *testing.T) { ctx := context.Background() err1 := pkgerrors.Error(pkgerrors.CodeType, ctx, "int", "float32") err2 := pkgerrors.Error(pkgerrors.CodeType, ctx, "int", "float32") - colErr := pkgerrors.Collection( - err1, - err2, - ) - + colErr := pkgerrors.Join(err1, err2) if colErr == nil { t.Fatal("Expected error to not be nil") - } else if s := len(colErr); s != 2 { - t.Fatalf("Expected error to have size %d, got %d", 2, s) } + if colErr.Code() != pkgerrors.CodeType { + t.Errorf("Expected Code() to return CodeType, got: %s", colErr.Code()) + } +} + +// TestCollectionDelegation tests that Path, PathAs, ShortError, DocsURI, TraceURI, Meta, and Params +// on a joined (multi) error delegate to the first error. +func TestCollectionDelegation(t *testing.T) { + ctx := rulecontext.WithPathString(context.Background(), "field") + err1 := pkgerrors.Errorf(pkgerrors.CodeMin, ctx, "above minimum", "value at least %d", 10) + err2 := pkgerrors.Errorf(pkgerrors.CodeMax, ctx, "above maximum", "value at most %d", 100) - first := colErr.First() + col := pkgerrors.Join(err1, err2) + if col == nil { + t.Fatal("Expected joined error to not be nil") + } - if first == nil { - t.Errorf("Expected first to not be nil") - } else if first != err1 && first != err2 { - t.Errorf("Expected one of two errors to be returned") + if p := col.Path(); p != "/field" { + t.Errorf("Expected Path() to return first error path /field, got: %s", p) + } + if p := col.PathAs(pkgerrors.DefaultPathSerializer{}); p != "/field" { + t.Errorf("Expected PathAs() to return first error path /field, got: %s", p) + } + if s := col.ShortError(); s != "above minimum" { + t.Errorf("Expected ShortError() to return first error short, got: %s", s) + } + if u := col.DocsURI(); u != "" { + t.Errorf("Expected DocsURI() to return first error docs URI, got: %s", u) + } + if u := col.TraceURI(); u != "" { + t.Errorf("Expected TraceURI() to return first error trace URI, got: %s", u) + } + if m := col.Meta(); m != nil { + t.Errorf("Expected Meta() to return first error meta, got: %v", m) + } + if params := col.Params(); len(params) != 1 || params[0] != 10 { + t.Errorf("Expected Params() to return first error params [10], got: %v", params) } } -// TestCollectionFirstEmpty tests: -// - Returns nil when collection is empty -func TestCollectionFirstEmpty(t *testing.T) { - col := pkgerrors.Collection() - if first := col.First(); first != nil { - t.Errorf("Expected first to be nil, got: %s", first) +// TestUnwrapNil tests that pkgerrors.Unwrap(nil) returns nil (Unwrap() on nil is not called). +func TestUnwrapNil(t *testing.T) { + all := pkgerrors.Unwrap(nil) + if all != nil { + t.Errorf("Expected nil, got length %d", len(all)) } } @@ -153,50 +198,43 @@ func TestCollectionFor(t *testing.T) { ctx2 = rulecontext.WithPathString(ctx2, "b") err2 := pkgerrors.Errorf(pkgerrors.CodeMax, ctx2, "above maximum", "error2") - colErr := pkgerrors.Collection( - err1, - err2, - ) + colErr := pkgerrors.Join(err1, err2) if colErr == nil { t.Fatal("Expected error to not be nil") - } else if s := len(colErr); s != 2 { + } + if s := len(pkgerrors.Unwrap(colErr)); s != 2 { t.Fatalf("Expected error to have size %d, got %d", 2, s) } - path1err := colErr.For("/path1") - + path1err := pkgerrors.For(colErr, "/path1") if path1err == nil { t.Errorf("Expected path1 error to not be nil") - } else if s := len(path1err); s != 1 { + } else if s := len(pkgerrors.Unwrap(path1err)); s != 1 { t.Errorf("Expected a collection with 1 error, got: '%d'", s) - } else if first := path1err.First(); first != err1 { + } else if first := pkgerrors.Unwrap(path1err)[0]; first != err1 { t.Errorf("Expected '%s' to be returned, got: '%s'", err1, first) } - path1err = colErr.For("/path1/b") - + path1err = pkgerrors.For(colErr, "/path1/b") if path1err != nil { t.Errorf("Expected error to be nil, got: %s", path1err) } - path2err := colErr.For("/path2a/b") - + path2err := pkgerrors.For(colErr, "/path2a/b") if path2err == nil { t.Errorf("Expected path2 error to not be nil") - } else if s := len(path2err); s != 1 { + } else if s := len(pkgerrors.Unwrap(path2err)); s != 1 { t.Errorf("Expected a collection with 1 error, got: '%d'", s) - } else if first := path2err.First(); first != err2 { + } else if first := pkgerrors.Unwrap(path2err)[0]; first != err2 { t.Errorf("Expected '%s' to be returned, got: '%s'", err2, first) } } -// TestCollectionForEmpty tests: -// - Returns nil when collection is empty -func TestCollectionForEmpty(t *testing.T) { - col := pkgerrors.Collection() - if first := col.For("a"); first != nil { - t.Errorf("Expected first to be nil, got: %s", first) +// TestCollectionForNil tests that For(nil, path) returns nil. +func TestCollectionForNil(t *testing.T) { + if result := pkgerrors.For(nil, "a"); result != nil { + t.Errorf("Expected For(nil, path) to be nil, got: %s", result) } } @@ -206,31 +244,18 @@ func TestCollectionForEmpty(t *testing.T) { func TestCollectionMessage(t *testing.T) { err := pkgerrors.Errorf(pkgerrors.CodeUnknown, context.Background(), "unknown error", "error123") - col := pkgerrors.Collection(err) - + col := pkgerrors.Join(err) if msg := col.Error(); msg != "error123" { t.Errorf("Expected error message to be %s, got: %s", "error123", msg) } - col = append(col, pkgerrors.Errorf(pkgerrors.CodeUnknown, context.Background(), "unknown error", "error123")) - + err2 := pkgerrors.Errorf(pkgerrors.CodeUnknown, context.Background(), "unknown error", "error123") + col = pkgerrors.Join(err, err2) if msg := col.Error(); !strings.Contains(msg, "(and 1 more)") { t.Errorf("Expected error message to contain the string '(and 1 more)', got: %s", msg) } } -// TestPanicCollectionMessageEmpty tests: -// - Panics when Error is called on an empty collection -func TestPanicCollectionMessageEmpty(t *testing.T) { - defer func() { - if r := recover(); r == nil { - t.Errorf("Expected panic") - } - }() - - _ = pkgerrors.Collection().Error() -} - // TestValidationErrorInternal tests: // - Internal() returns true for internal error codes // - Internal() returns false for validation error codes @@ -306,18 +331,11 @@ func TestValidationErrorPermission(t *testing.T) { // TestCollectionInternal tests: // - Internal() returns true if any error is internal // - Internal() returns false if no errors are internal -// - Internal() returns false for empty collections func TestCollectionInternal(t *testing.T) { ctx := context.Background() - // Empty collection - emptyCol := pkgerrors.Collection() - if emptyCol.Internal() { - t.Error("Expected empty collection Internal() to return false") - } - // Collection with only validation errors - validationCol := pkgerrors.Collection( + validationCol := pkgerrors.Join( pkgerrors.Error(pkgerrors.CodeMin, ctx, 10), pkgerrors.Error(pkgerrors.CodeMax, ctx, 100), ) @@ -326,7 +344,7 @@ func TestCollectionInternal(t *testing.T) { } // Collection with one internal error - mixedCol := pkgerrors.Collection( + mixedCol := pkgerrors.Join( pkgerrors.Error(pkgerrors.CodeMin, ctx, 10), pkgerrors.Error(pkgerrors.CodeInternal, ctx), ) @@ -339,18 +357,11 @@ func TestCollectionInternal(t *testing.T) { // - Permission() returns true if any error is permission and none are internal // - Permission() returns false if any error is internal // - Permission() returns false if all errors are validation -// - Permission() returns false for empty collections func TestCollectionPermission(t *testing.T) { ctx := context.Background() - // Empty collection - emptyCol := pkgerrors.Collection() - if emptyCol.Permission() { - t.Error("Expected empty collection Permission() to return false") - } - // Collection with only validation errors - validationCol := pkgerrors.Collection( + validationCol := pkgerrors.Join( pkgerrors.Error(pkgerrors.CodeMin, ctx, 10), pkgerrors.Error(pkgerrors.CodeMax, ctx, 100), ) @@ -359,7 +370,7 @@ func TestCollectionPermission(t *testing.T) { } // Collection with permission error - permissionCol := pkgerrors.Collection( + permissionCol := pkgerrors.Join( pkgerrors.Error(pkgerrors.CodeMin, ctx, 10), pkgerrors.Error(pkgerrors.CodeForbidden, ctx), ) @@ -368,7 +379,7 @@ func TestCollectionPermission(t *testing.T) { } // Collection with internal and permission errors - internal takes precedence - internalAndPermissionCol := pkgerrors.Collection( + internalAndPermissionCol := pkgerrors.Join( pkgerrors.Error(pkgerrors.CodeInternal, ctx), pkgerrors.Error(pkgerrors.CodeForbidden, ctx), ) @@ -380,18 +391,11 @@ func TestCollectionPermission(t *testing.T) { // TestCollectionValidation tests: // - Validation() returns true if all errors are validation // - Validation() returns false if any error is internal or permission -// - Validation() returns false for empty collections func TestCollectionValidation(t *testing.T) { ctx := context.Background() - // Empty collection - emptyCol := pkgerrors.Collection() - if emptyCol.Validation() { - t.Error("Expected empty collection Validation() to return false") - } - // Collection with only validation errors - validationCol := pkgerrors.Collection( + validationCol := pkgerrors.Join( pkgerrors.Error(pkgerrors.CodeMin, ctx, 10), pkgerrors.Error(pkgerrors.CodeMax, ctx, 100), ) @@ -400,7 +404,7 @@ func TestCollectionValidation(t *testing.T) { } // Collection with internal error - internalCol := pkgerrors.Collection( + internalCol := pkgerrors.Join( pkgerrors.Error(pkgerrors.CodeMin, ctx, 10), pkgerrors.Error(pkgerrors.CodeInternal, ctx), ) @@ -409,7 +413,7 @@ func TestCollectionValidation(t *testing.T) { } // Collection with permission error - permissionCol := pkgerrors.Collection( + permissionCol := pkgerrors.Join( pkgerrors.Error(pkgerrors.CodeMin, ctx, 10), pkgerrors.Error(pkgerrors.CodeForbidden, ctx), ) @@ -418,16 +422,13 @@ func TestCollectionValidation(t *testing.T) { } } -// TestCollectionUnwrapEmpty tests: -// - Unwrap returns an empty slice for empty collections -func TestCollectionUnwrapEmpty(t *testing.T) { - col := pkgerrors.Collection() - unwrapped := col.Unwrap() - if unwrapped == nil { - t.Error("Expected Unwrap() to return an empty slice, not nil") - } - if len(unwrapped) != 0 { - t.Errorf("Expected Unwrap() to return empty slice, got length %d", len(unwrapped)) +// TestUnwrapSingle tests that a single error's Unwrap() returns nil and pkgerrors.Unwrap returns []error{err}. +func TestUnwrapSingle(t *testing.T) { + ctx := context.Background() + err := pkgerrors.Error(pkgerrors.CodeMin, ctx, 10) + all := pkgerrors.Unwrap(err) + if len(all) != 1 || all[0] != err { + t.Errorf("Expected one error, got: %d", len(all)) } } @@ -443,17 +444,15 @@ func TestCollectionErrorsIs(t *testing.T) { err2 := pkgerrors.Error(pkgerrors.CodeMax, ctx, 100) err3 := pkgerrors.Error(pkgerrors.CodeType, ctx, "int", "string") - // Single error collection - col1 := pkgerrors.Collection(err1) - if !errors.Is(col1, err1) { + // Single error + if !errors.Is(err1, err1) { t.Error("Expected errors.Is to return true for error in single-error collection") } - if errors.Is(col1, err2) { - t.Error("Expected errors.Is to return false for error not in collection") + if errors.Is(err1, err2) { + t.Error("Expected errors.Is to return false for different error") } - // Multiple error collection - col2 := pkgerrors.Collection(err1, err2) + col2 := pkgerrors.Join(err1, err2) if !errors.Is(col2, err1) { t.Error("Expected errors.Is to return true for first error in collection") } @@ -464,10 +463,9 @@ func TestCollectionErrorsIs(t *testing.T) { t.Error("Expected errors.Is to return false for error not in collection") } - // Empty collection - emptyCol := pkgerrors.Collection() - if errors.Is(emptyCol, err1) { - t.Error("Expected errors.Is to return false for empty collection") + // Nil + if errors.Is(nil, err1) { + t.Error("Expected errors.Is(nil, err) to return false") } } @@ -482,18 +480,17 @@ func TestCollectionErrorsAs(t *testing.T) { err1 := pkgerrors.Error(pkgerrors.CodeMin, ctx, 10) err2 := pkgerrors.Error(pkgerrors.CodeMax, ctx, 100) - // Single error collection - col1 := pkgerrors.Collection(err1) + // Single error var extractedErr pkgerrors.ValidationError - if !errors.As(col1, &extractedErr) { - t.Error("Expected errors.As to return true and extract ValidationError from single-error collection") + if !errors.As(err1, &extractedErr) { + t.Error("Expected errors.As to return true and extract ValidationError from single error") } if extractedErr != err1 { - t.Error("Expected extracted error to match the error in collection") + t.Error("Expected extracted error to match") } // Multiple error collection - should extract first matching error - col2 := pkgerrors.Collection(err1, err2) + col2 := pkgerrors.Join(err1, err2) extractedErr = nil if !errors.As(col2, &extractedErr) { t.Error("Expected errors.As to return true and extract ValidationError from multi-error collection") @@ -501,30 +498,28 @@ func TestCollectionErrorsAs(t *testing.T) { if extractedErr == nil { t.Error("Expected extracted error to not be nil") } - // The extracted error should be one of the errors in the collection - if extractedErr != err1 && extractedErr != err2 { - t.Error("Expected extracted error to be one of the errors in the collection") + // The extracted error should have a code from the collection (As may return the multiError or first wrapped error) + if extractedErr.Code() != pkgerrors.CodeMin && extractedErr.Code() != pkgerrors.CodeMax { + t.Errorf("Expected extracted error code to be CodeMin or CodeMax, got %v", extractedErr.Code()) } // Test with a different error code to verify it extracts correctly err3 := pkgerrors.Error(pkgerrors.CodeType, ctx, "int", "string") - col3 := pkgerrors.Collection(err3) extractedErr = nil - if !errors.As(col3, &extractedErr) { + if !errors.As(err3, &extractedErr) { t.Error("Expected errors.As to return true for different error code") } if extractedErr.Code() != pkgerrors.CodeType { t.Errorf("Expected extracted error to have CodeType code, got %v", extractedErr.Code()) } - // Empty collection - emptyCol := pkgerrors.Collection() + // Nil extractedErr = nil - if errors.As(emptyCol, &extractedErr) { - t.Error("Expected errors.As to return false for empty collection") + if errors.As(nil, &extractedErr) { + t.Error("Expected errors.As(nil, &extractedErr) to return false") } if extractedErr != nil { - t.Error("Expected extracted error to remain nil for empty collection") + t.Error("Expected extracted error to remain nil") } } @@ -537,8 +532,9 @@ func TestCollectionErrorsAsWithNestedErrors(t *testing.T) { err2 := pkgerrors.Error(pkgerrors.CodeMax, ctx, 100) // Create a collection and then wrap it (simulating nested error scenarios) - innerCol := pkgerrors.Collection(err1, err2) - outerCol := pkgerrors.Collection(innerCol.First(), pkgerrors.Error(pkgerrors.CodeType, ctx, "int", "string")) + innerCol := pkgerrors.Join(err1, err2) + first := pkgerrors.Unwrap(innerCol)[0] + outerCol := pkgerrors.Join(first, pkgerrors.Error(pkgerrors.CodeType, ctx, "int", "string")) var extractedErr pkgerrors.ValidationError if !errors.As(outerCol, &extractedErr) { diff --git a/pkg/errors/for_path_as_test.go b/pkg/errors/for_path_as_test.go index 676a790..1992996 100644 --- a/pkg/errors/for_path_as_test.go +++ b/pkg/errors/for_path_as_test.go @@ -19,17 +19,15 @@ func TestForPathAs_WithDefaultSerializer(t *testing.T) { ctx2 = rulecontext.WithPathString(ctx2, "c") err2 := errors.Errorf(errors.CodeMin, ctx2, "short", "message2") - collection := errors.Collection(err1, err2) - + collection := errors.Join(err1, err2) serializer := errors.DefaultPathSerializer{} - filtered := collection.ForPathAs("/a/b", serializer) - - if len(filtered) != 1 { - t.Errorf("Expected 1 error, got: %d", len(filtered)) + filtered := errors.ForPathAs(collection, "/a/b", serializer) + all := errors.Unwrap(filtered) + if len(all) != 1 { + t.Errorf("Expected 1 error, got: %d", len(all)) } - - if filtered[0].Error() != "message1" { - t.Errorf("Expected error message 'message1', got: '%s'", filtered[0].Error()) + if all[0].Error() != "message1" { + t.Errorf("Expected error message 'message1', got: '%s'", all[0].Error()) } } @@ -44,13 +42,12 @@ func TestForPathAs_WithJSONPointerSerializer(t *testing.T) { ctx2 = rulecontext.WithPathString(ctx2, "c") err2 := errors.Errorf(errors.CodeMin, ctx2, "short", "message2") - collection := errors.Collection(err1, err2) + collection := errors.Join(err1, err2) serializer := errors.JSONPointerSerializer{} - filtered := collection.ForPathAs("/a/b", serializer) - - if len(filtered) != 1 { - t.Errorf("Expected 1 error, got: %d", len(filtered)) + filtered := errors.ForPathAs(collection, "/a/b", serializer) + if len(errors.Unwrap(filtered)) != 1 { + t.Errorf("Expected 1 error, got: %d", len(errors.Unwrap(filtered))) } } @@ -65,13 +62,12 @@ func TestForPathAs_WithJSONPathSerializer(t *testing.T) { ctx2 = rulecontext.WithPathString(ctx2, "c") err2 := errors.Errorf(errors.CodeMin, ctx2, "short", "message2") - collection := errors.Collection(err1, err2) + collection := errors.Join(err1, err2) serializer := errors.JSONPathSerializer{} - filtered := collection.ForPathAs("$.a.b", serializer) - - if len(filtered) != 1 { - t.Errorf("Expected 1 error, got: %d", len(filtered)) + filtered := errors.ForPathAs(collection, "$.a.b", serializer) + if len(errors.Unwrap(filtered)) != 1 { + t.Errorf("Expected 1 error, got: %d", len(errors.Unwrap(filtered))) } } @@ -86,13 +82,12 @@ func TestForPathAs_WithDotNotationSerializer(t *testing.T) { ctx2 = rulecontext.WithPathString(ctx2, "c") err2 := errors.Errorf(errors.CodeMin, ctx2, "short", "message2") - collection := errors.Collection(err1, err2) + collection := errors.Join(err1, err2) serializer := errors.DotNotationSerializer{} - filtered := collection.ForPathAs("a.b", serializer) - - if len(filtered) != 1 { - t.Errorf("Expected 1 error, got: %d", len(filtered)) + filtered := errors.ForPathAs(collection, "a.b", serializer) + if len(errors.Unwrap(filtered)) != 1 { + t.Errorf("Expected 1 error, got: %d", len(errors.Unwrap(filtered))) } } @@ -103,25 +98,20 @@ func TestForPathAs_NoMatches(t *testing.T) { ctx = rulecontext.WithPathString(ctx, "b") err := errors.Errorf(errors.CodeMin, ctx, "short", "message") - collection := errors.Collection(err) + collection := errors.Join(err) serializer := errors.DefaultPathSerializer{} - filtered := collection.ForPathAs("/nonexistent", serializer) - - if len(filtered) != 0 { - t.Errorf("Expected empty collection, got: %d errors", len(filtered)) + filtered := errors.ForPathAs(collection, "/nonexistent", serializer) + if filtered != nil && len(errors.Unwrap(filtered)) != 0 { + t.Errorf("Expected no errors, got: %d", len(errors.Unwrap(filtered))) } } -// TestForPathAs_EmptyCollection tests: -// - ForPathAs handles empty collection -func TestForPathAs_EmptyCollection(t *testing.T) { - collection := errors.Collection() - +// TestForPathAs_Nil tests that ForPathAs(nil, path, serializer) returns nil. +func TestForPathAs_Nil(t *testing.T) { serializer := errors.DefaultPathSerializer{} - filtered := collection.ForPathAs("/a/b", serializer) - - if len(filtered) != 0 { - t.Errorf("Expected nil or empty collection, got: %d errors", len(filtered)) + filtered := errors.ForPathAs(nil, "/a/b", serializer) + if filtered != nil { + t.Errorf("Expected nil, got: %v", filtered) } } diff --git a/pkg/errors/range_error_test.go b/pkg/errors/range_error_test.go new file mode 100644 index 0000000..ae759bb --- /dev/null +++ b/pkg/errors/range_error_test.go @@ -0,0 +1,22 @@ +package errors_test + +import ( + "context" + "testing" + + pkgerrors "proto.zip/studio/validate/pkg/errors" +) + +func TestNewRangeError(t *testing.T) { + ctx := context.Background() + err := pkgerrors.NewRangeError(ctx, "int8") + if err == nil { + t.Fatal("NewRangeError should not return nil") + } + if err.Code() != pkgerrors.CodeRange { + t.Errorf("Code() = %s, want %s", err.Code(), pkgerrors.CodeRange) + } + if err.Error() == "" { + t.Error("Error() should not be empty") + } +} diff --git a/pkg/errors/validation_error.go b/pkg/errors/validation_error.go index b27955b..729e976 100644 --- a/pkg/errors/validation_error.go +++ b/pkg/errors/validation_error.go @@ -6,36 +6,72 @@ import ( "proto.zip/studio/validate/pkg/rulecontext" ) -// ValidationError stores information necessary to identify where the validation error -// is, as well as implementing the Error interface to work with standard errors. +// ValidationError is the interface for validation errors. It extends the standard error +// interface with Unwrap() []error for multiple errors and rich metadata (Code, Path, etc.). +// When an error wraps multiple errors (Unwrap() returns non-empty), Code(), Path(), and +// similar methods return the first error's information. type ValidationError interface { - Code() ErrorCode // Code returns the error code. - Path() string // Path returns the full path to the error as a string. - PathAs(serializer PathSerializer) string // PathAs returns the full path using the provided serializer. - Error() string // Error returns the detailed error message (satisfies Go error interface). - ShortError() string // ShortError returns a brief constant error description. - DocsURI() string // DocsURI returns an optional documentation URL for the error. - TraceURI() string // TraceURI returns an optional trace/debug URL for the error. - Meta() map[string]any // Meta returns arbitrary metadata associated with the error. - Params() []any // Params returns the format arguments used to create the error message. - - // Type classification methods - Internal() bool // Internal returns true if the error is an internal error. - Validation() bool // Validation returns true if the error is a validation error. - Permission() bool // Permission returns true if the error is a permission error. -} - -// validationError implements a standard Error interface and also ValidationError interface -// while preserving the validation data. -type validationError struct { - code ErrorCode // Error code helps identify the error without string comparisons. - pathSegment rulecontext.PathSegment // The leaf path segment (nil if no path). - message string // The error message (long description) converted to the context locale. - docsURI string // Optional documentation URL. - traceURI string // Optional trace/debug URL. - shortMsg string // Brief constant description. - meta map[string]any // Arbitrary metadata. - params []any // Format arguments used to create the message. + error + + // Unwrap returns the list of wrapped errors for use with errors.Is and errors.As. + // Returns nil for a single error (no wrapped errors). Nil receiver returns nil. + Unwrap() []error + + // Code returns the error code. For multi-errors, returns the first error's code. + Code() ErrorCode + + // Path returns the full path to the error (e.g. "/field/subfield"). For multi-errors, returns the first error's path. + Path() string + + // PathAs returns the path serialized with the given serializer (e.g. JSON Pointer, JSONPath). + PathAs(serializer PathSerializer) string + + // ShortError returns a brief, constant description suitable for API responses. + ShortError() string + + // DocsURI returns an optional documentation URL for this error code. + DocsURI() string + + // TraceURI returns an optional trace or debug URL. + TraceURI() string + + // Meta returns arbitrary key-value metadata attached to the error. + Meta() map[string]any + + // Params returns the format arguments used to build the long error message. + Params() []any + + // Internal returns true if any wrapped error is classified as internal (e.g. CodeInternal, CodeTimeout). + Internal() bool + + // Validation returns true if all wrapped errors are validation errors (user input issues). + Validation() bool + + // Permission returns true if any wrapped error is a permission/authorization error and none are internal. + Permission() bool +} + +// singleError is the concrete type for a single validation error. +type singleError struct { + code ErrorCode + pathSegment rulecontext.PathSegment + message string + docsURI string + traceURI string + shortMsg string + meta map[string]any + params []any +} + +// Ensure singleError implements ValidationError. +var _ ValidationError = (*singleError)(nil) + +// Unwrap returns nil for a single error (no wrapped errors). Nil receiver returns nil. +func (err *singleError) Unwrap() []error { + if err == nil { + return nil + } + return nil } // Errorf creates a new ValidationError with explicit short and long messages. @@ -72,7 +108,7 @@ func Errorf(code ErrorCode, ctx context.Context, short, long string, args ...int meta = config.Meta } - err := ValidationError(&validationError{ + err := ValidationError(&singleError{ code: actualCode, pathSegment: segment, message: printer.Sprintf(actualLong, args...), @@ -99,26 +135,23 @@ func Error(code ErrorCode, ctx context.Context, args ...interface{}) ValidationE return Errorf(code, ctx, dict.ShortError(code), dict.ErrorPattern(code), args...) } -// Error implements the standard Error interface to return a string for validation errors. -// Error loses contextual data, so use the ValidationError object when possible. -func (err *validationError) Error() string { +// Error implements the error interface and returns the long-form message. +func (err *singleError) Error() string { return err.message } // Code returns the error code. -// Code can be used to look up the error without relying on string checks. -func (err *validationError) Code() ErrorCode { +func (err *singleError) Code() ErrorCode { return err.code } -// Path returns the full path to the error as a string. -// Path is a wrapper around PathAs using the default serializer. -func (err *validationError) Path() string { +// Path returns the full path using the default serializer. +func (err *singleError) Path() string { return err.PathAs(DefaultPathSerializer{}) } -// PathAs returns the full path to the error as a string using the provided serializer. -func (err *validationError) PathAs(serializer PathSerializer) string { +// PathAs returns the path serialized with the given serializer. +func (err *singleError) PathAs(serializer PathSerializer) string { var segments []rulecontext.PathSegment if err.pathSegment != nil { segments = extractPathSegments(err.pathSegment) @@ -126,43 +159,42 @@ func (err *validationError) PathAs(serializer PathSerializer) string { return serializer.Serialize(segments) } -// DocsURI returns an optional documentation URL for the error. -func (err *validationError) DocsURI() string { +// DocsURI returns the optional documentation URI. +func (err *singleError) DocsURI() string { return err.docsURI } -// TraceURI returns an optional trace/debug URL for the error. -func (err *validationError) TraceURI() string { +// TraceURI returns the optional trace/debug URI. +func (err *singleError) TraceURI() string { return err.traceURI } -// ShortError returns a brief constant error description. -func (err *validationError) ShortError() string { +// ShortError returns the brief, constant description. +func (err *singleError) ShortError() string { return err.shortMsg } -// Meta returns arbitrary metadata associated with the error. -func (err *validationError) Meta() map[string]any { +// Meta returns the metadata map, if any. +func (err *singleError) Meta() map[string]any { return err.meta } -// Params returns the format arguments used to create the error message. -// This allows callbacks to access the original values for custom formatting. -func (err *validationError) Params() []any { +// Params returns the format arguments for the long message. +func (err *singleError) Params() []any { return err.params } -// Internal returns true if the error is classified as an internal error. -func (err *validationError) Internal() bool { +// Internal returns true if the error code is classified as internal. +func (err *singleError) Internal() bool { return DefaultDict().ErrorType(err.code) == ErrorTypeInternal } -// Validation returns true if the error is classified as a validation error. -func (err *validationError) Validation() bool { +// Validation returns true if the error code is classified as a validation error. +func (err *singleError) Validation() bool { return DefaultDict().ErrorType(err.code) == ErrorTypeValidation } -// Permission returns true if the error is classified as a permission error. -func (err *validationError) Permission() bool { +// Permission returns true if the error code is classified as a permission error. +func (err *singleError) Permission() bool { return DefaultDict().ErrorType(err.code) == ErrorTypePermission } diff --git a/pkg/rules/any.go b/pkg/rules/any.go index bfa9796..a0c783b 100644 --- a/pkg/rules/any.go +++ b/pkg/rules/any.go @@ -92,8 +92,8 @@ func (v *AnyRuleSet) WithNil() *AnyRuleSet { } // Apply performs validation of a RuleSet against a value and assigns the value to the output. -// Apply returns a ValidationErrorCollection. -func (v *AnyRuleSet) Apply(ctx context.Context, input, output any) errors.ValidationErrorCollection { +// Apply returns a ValidationError. +func (v *AnyRuleSet) Apply(ctx context.Context, input, output any) errors.ValidationError { // Add error config to context for error customization ctx = errors.WithErrorConfig(ctx, v.errorConfig) @@ -110,9 +110,7 @@ func (v *AnyRuleSet) Apply(ctx context.Context, input, output any) errors.Valida // Ensure output is a pointer rv := reflect.ValueOf(output) if rv.Kind() != reflect.Ptr || rv.IsNil() { - return errors.Collection( - errors.Errorf(errors.CodeInternal, ctx, "internal error", "Output must be a non-nil pointer"), - ) + return errors.Errorf(errors.CodeInternal, ctx, "internal error", "Output must be a non-nil pointer") } // Get the element the pointer points to @@ -127,39 +125,29 @@ func (v *AnyRuleSet) Apply(ctx context.Context, input, output any) errors.Valida return nil } - return errors.Collection( - errors.Errorf(errors.CodeInternal, ctx, "internal error", "Cannot assign %T to %T", input, output), - ) + return errors.Errorf(errors.CodeInternal, ctx, "internal error", "Cannot assign %T to %T", input, output) } -// Evaluate performs validation of a RuleSet against a value and returns a ValidationErrorCollection. +// Evaluate performs validation of a RuleSet against a value and returns a ValidationError. // Evaluate calls wrapped rules before any rules added directly to the AnyRuleSet. -func (v *AnyRuleSet) Evaluate(ctx context.Context, value any) errors.ValidationErrorCollection { +func (v *AnyRuleSet) Evaluate(ctx context.Context, value any) errors.ValidationError { if v.forbidden { - return errors.Collection(errors.Error(errors.CodeForbidden, ctx)) + return errors.Error(errors.CodeForbidden, ctx) } - allErrors := errors.Collection() - + var errs errors.ValidationError currentRuleSet := v ctx = rulecontext.WithRuleSet(ctx, v) for currentRuleSet != nil { if currentRuleSet.rule != nil { - err := currentRuleSet.rule.Evaluate(ctx, value) - if err != nil { - allErrors = append(allErrors, err...) + if err := currentRuleSet.rule.Evaluate(ctx, value); err != nil { + errs = errors.Join(errs, err) } } - currentRuleSet = currentRuleSet.parent } - - if len(allErrors) != 0 { - return allErrors - } else { - return nil - } + return errs } // WithRule returns a new child rule set that applies a custom validation rule. diff --git a/pkg/rules/bool.go b/pkg/rules/bool.go index 6607768..e688207 100644 --- a/pkg/rules/bool.go +++ b/pkg/rules/bool.go @@ -95,6 +95,7 @@ type boolConflictTypeReplacesWrapper struct { ct boolConflictType } +// Replaces returns true if the other rule is a BoolRuleSet with a conflicting conflict type. func (w boolConflictTypeReplacesWrapper) Replaces(r Rule[bool]) bool { // Try to cast to BoolRuleSet to access conflictType if rs, ok := r.(interface{ getConflictType() boolConflictType }); ok { @@ -134,8 +135,8 @@ func (v *BoolRuleSet) WithNil() *BoolRuleSet { } // Apply performs validation of a RuleSet against a value and assigns the result to the output parameter. -// Apply returns a ValidationErrorCollection if any validation errors occur. -func (ruleSet *BoolRuleSet) Apply(ctx context.Context, input any, output any) errors.ValidationErrorCollection { +// Apply returns a ValidationError if any validation errors occur. +func (ruleSet *BoolRuleSet) Apply(ctx context.Context, input any, output any) errors.ValidationError { // Add error config to context for error customization ctx = errors.WithErrorConfig(ctx, ruleSet.errorConfig) @@ -147,15 +148,13 @@ func (ruleSet *BoolRuleSet) Apply(ctx context.Context, input any, output any) er // Ensure output is a non-nil pointer outputVal := reflect.ValueOf(output) if outputVal.Kind() != reflect.Ptr || outputVal.IsNil() { - return errors.Collection(errors.Errorf( - errors.CodeInternal, ctx, "internal error", "Output must be a non-nil pointer", - )) + return errors.Errorf(errors.CodeInternal, ctx, "internal error", "Output must be a non-nil pointer") } // Attempt to coerce the input value to a boolean boolval, validationErr := ruleSet.coerceBool(input, ctx) if validationErr != nil { - return errors.Collection(validationErr) + return validationErr } // Handle setting the value in output @@ -190,44 +189,31 @@ func (ruleSet *BoolRuleSet) Apply(ctx context.Context, input any, output any) er // If the types are incompatible, return an error if !assignable { - return errors.Collection(errors.Errorf( - errors.CodeInternal, ctx, "internal error", "Cannot assign %T to %T", boolval, outputElem.Interface(), - )) + return errors.Errorf(errors.CodeInternal, ctx, "internal error", "Cannot assign %T to %T", boolval, outputElem.Interface()) } - allErrors := errors.Collection() - + var errs errors.ValidationError for currentRuleSet := ruleSet; currentRuleSet != nil; currentRuleSet = currentRuleSet.parent { if currentRuleSet.rule != nil { if err := currentRuleSet.rule.Evaluate(ctx, boolval); err != nil { - allErrors = append(allErrors, err...) + errs = errors.Join(errs, err) } } } - - if len(allErrors) != 0 { - return allErrors - } - return nil + return errs } -// Evaluate performs validation of a RuleSet against a boolean value and returns a ValidationErrorCollection. -func (v *BoolRuleSet) Evaluate(ctx context.Context, value bool) errors.ValidationErrorCollection { - allErrors := errors.Collection() - +// Evaluate performs validation of a RuleSet against a boolean value and returns a ValidationError. +func (v *BoolRuleSet) Evaluate(ctx context.Context, value bool) errors.ValidationError { + var errs errors.ValidationError for currentRuleSet := v; currentRuleSet != nil; currentRuleSet = currentRuleSet.parent { if currentRuleSet.rule != nil { if err := currentRuleSet.rule.Evaluate(ctx, value); err != nil { - allErrors = append(allErrors, err...) + errs = errors.Join(errs, err) } } } - - if len(allErrors) != 0 { - return allErrors - } else { - return nil - } + return errs } // noConflict returns the new rule set with all conflicting rules removed. diff --git a/pkg/rules/bool_private_test.go b/pkg/rules/bool_private_test.go index 1a8eee7..64b272c 100644 --- a/pkg/rules/bool_private_test.go +++ b/pkg/rules/bool_private_test.go @@ -200,7 +200,7 @@ func TestBoolConflictTypeReplacesWrapper_Replaces_False(t *testing.T) { // mockRuleWithoutConflictType is a mock rule that doesn't implement getConflictType type mockRuleWithoutConflictType struct{} -func (m *mockRuleWithoutConflictType) Evaluate(ctx context.Context, value bool) errors.ValidationErrorCollection { +func (m *mockRuleWithoutConflictType) Evaluate(ctx context.Context, value bool) errors.ValidationError { return nil } diff --git a/pkg/rules/bool_test.go b/pkg/rules/bool_test.go index 3a8b989..3e5bed0 100644 --- a/pkg/rules/bool_test.go +++ b/pkg/rules/bool_test.go @@ -5,6 +5,7 @@ import ( "reflect" "testing" + "proto.zip/studio/validate/pkg/errors" "proto.zip/studio/validate/pkg/rules" "proto.zip/studio/validate/pkg/testhelpers" ) @@ -42,7 +43,7 @@ func TestBoolRuleSet_Apply_StrictError(t *testing.T) { var out bool err := rules.Bool().WithStrict().Apply(context.Background(), "true", &out) - if len(err) == 0 { + if len(errors.Unwrap(err)) == 0 { t.Error("Expected errors to not be empty") return } @@ -85,7 +86,7 @@ func TestBoolRuleSet_Apply_CoerceFromString_Invalid(t *testing.T) { var out bool err := rules.Bool().Apply(context.Background(), "invalid", &out) - if len(err) == 0 { + if len(errors.Unwrap(err)) == 0 { t.Error("Expected errors to not be empty") return } @@ -161,7 +162,7 @@ func TestBoolRuleSet_Apply_StrictMode_Int(t *testing.T) { var out bool err := rules.Bool().WithStrict().Apply(context.Background(), 1, &out) - if len(err) == 0 { + if len(errors.Unwrap(err)) == 0 { t.Error("Expected errors to not be empty") return } @@ -173,7 +174,7 @@ func TestBoolRuleSet_Apply_StrictMode_Float(t *testing.T) { var out bool err := rules.Bool().WithStrict().Apply(context.Background(), 1.0, &out) - if len(err) == 0 { + if len(errors.Unwrap(err)) == 0 { t.Error("Expected errors to not be empty") return } @@ -195,7 +196,7 @@ func TestBoolRuleSet_WithRuleFunc(t *testing.T) { WithRuleFunc(testhelpers.NewMockRuleWithErrors[bool](1).Function()). Apply(context.Background(), true, &out) - if len(err) == 0 { + if len(errors.Unwrap(err)) == 0 { t.Error("Expected errors to not be empty") return } @@ -436,7 +437,7 @@ func TestBoolRuleSet_Apply_PointerToBool_Nil(t *testing.T) { var nilBool *bool err := rules.Bool().Apply(context.Background(), nilBool, &out) - if len(err) == 0 { + if len(errors.Unwrap(err)) == 0 { t.Error("Expected errors to not be empty") return } @@ -448,7 +449,7 @@ func TestBoolRuleSet_Evaluate_WithErrors(t *testing.T) { ruleSet := rules.Bool().WithRuleFunc(testhelpers.NewMockRuleWithErrors[bool](1).Function()) err := ruleSet.Evaluate(context.Background(), true) - if len(err) == 0 { + if len(errors.Unwrap(err)) == 0 { t.Error("Expected errors to not be empty") return } @@ -490,7 +491,7 @@ func TestBoolRuleSet_coerceBool_EdgeCases(t *testing.T) { var out bool err := rules.Bool().WithStrict().Apply(context.Background(), []int{1, 2, 3}, &out) - if len(err) == 0 { + if len(errors.Unwrap(err)) == 0 { t.Error("Expected errors to not be empty") return } @@ -498,7 +499,7 @@ func TestBoolRuleSet_coerceBool_EdgeCases(t *testing.T) { // Test with unsupported type in non-strict mode err = rules.Bool().Apply(context.Background(), []int{1, 2, 3}, &out) - if len(err) == 0 { + if len(errors.Unwrap(err)) == 0 { t.Error("Expected errors to not be empty") return } diff --git a/pkg/rules/constant.go b/pkg/rules/constant.go index d1c0dfd..5b772c2 100644 --- a/pkg/rules/constant.go +++ b/pkg/rules/constant.go @@ -101,8 +101,8 @@ func (ruleSet *ConstantRuleSet[T]) WithNil() *ConstantRuleSet[T] { } // Apply validates a RuleSet against an input value and assigns the validated value to output. -// Apply returns a ValidationErrorCollection. -func (ruleSet *ConstantRuleSet[T]) Apply(ctx context.Context, input, output any) errors.ValidationErrorCollection { +// Apply returns a ValidationError. +func (ruleSet *ConstantRuleSet[T]) Apply(ctx context.Context, input, output any) errors.ValidationError { // Add error config to context for error customization ctx = errors.WithErrorConfig(ctx, ruleSet.errorConfig) @@ -115,14 +115,14 @@ func (ruleSet *ConstantRuleSet[T]) Apply(ctx context.Context, input, output any) v, ok := input.(T) if !ok { // Return a coercion error if input is not of type T. - return errors.Collection(errors.Error(errors.CodeType, ctx, reflect.TypeOf(ruleSet.empty).String(), reflect.TypeOf(input).String())) + return errors.Error(errors.CodeType, ctx, reflect.TypeOf(ruleSet.empty).String(), reflect.TypeOf(input).String()) } // Ensure the output is assignable to the coerced value. outVal := reflect.ValueOf(output) if outVal.Kind() != reflect.Ptr || outVal.IsNil() || !reflect.ValueOf(v).Type().AssignableTo(outVal.Elem().Type()) { // Return an error if the output is not assignable. - return errors.Collection(errors.Errorf(errors.CodeInternal, ctx, "internal error", "Cannot assign %T to %T", input, output)) + return errors.Errorf(errors.CodeInternal, ctx, "internal error", "Cannot assign %T to %T", input, output) } // Assign the validated value to the output. @@ -133,9 +133,9 @@ func (ruleSet *ConstantRuleSet[T]) Apply(ctx context.Context, input, output any) } // Evaluate performs validation of a RuleSet against a value and returns any errors. -func (ruleSet *ConstantRuleSet[T]) Evaluate(ctx context.Context, value T) errors.ValidationErrorCollection { +func (ruleSet *ConstantRuleSet[T]) Evaluate(ctx context.Context, value T) errors.ValidationError { if value != ruleSet.value { - return errors.Collection(errors.Errorf(errors.CodePattern, ctx, "value mismatch", "value does not match")) + return errors.Errorf(errors.CodePattern, ctx, "value mismatch", "value does not match") } return nil } diff --git a/pkg/rules/float.go b/pkg/rules/float.go index f1a3a2d..6874ac4 100644 --- a/pkg/rules/float.go +++ b/pkg/rules/float.go @@ -118,6 +118,7 @@ type floatConflictTypeReplacesWrapper[T floating] struct { ct floatConflictType } +// Replaces returns true if the other rule is a FloatRuleSet with a conflicting conflict type. func (w floatConflictTypeReplacesWrapper[T]) Replaces(r Rule[T]) bool { // Try to cast to FloatRuleSet to access conflictType if rs, ok := r.(interface{ getConflictType() floatConflictType }); ok { @@ -160,8 +161,8 @@ func (v *FloatRuleSet[T]) WithNil() *FloatRuleSet[T] { } // Apply performs validation of a RuleSet against a value and assigns the result to the output parameter. -// Apply returns a ValidationErrorCollection if any validation errors occur. -func (v *FloatRuleSet[T]) Apply(ctx context.Context, input any, output any) errors.ValidationErrorCollection { +// Apply returns a ValidationError if any validation errors occur. +func (v *FloatRuleSet[T]) Apply(ctx context.Context, input any, output any) errors.ValidationError { // Add error config to context for error customization ctx = errors.WithErrorConfig(ctx, v.errorConfig) @@ -173,15 +174,13 @@ func (v *FloatRuleSet[T]) Apply(ctx context.Context, input any, output any) erro // Ensure output is a non-nil pointer outputVal := reflect.ValueOf(output) if outputVal.Kind() != reflect.Ptr || outputVal.IsNil() { - return errors.Collection(errors.Errorf( - errors.CodeInternal, ctx, "internal error", "Output must be a non-nil pointer", - )) + return errors.Errorf(errors.CodeInternal, ctx, "internal error", "Output must be a non-nil pointer") } // Attempt to coerce the input value to the correct float type floatval, validationErr := v.coerceFloat(input, ctx) if validationErr != nil { - return errors.Collection(validationErr) + return validationErr } // Apply rounding if specified @@ -241,29 +240,22 @@ func (v *FloatRuleSet[T]) Apply(ctx context.Context, input any, output any) erro // If the types are incompatible, return an error if !assignable { - return errors.Collection(errors.Errorf( - errors.CodeInternal, ctx, "internal error", "Cannot assign %T to %T", floatval, outputElem.Interface(), - )) + return errors.Errorf(errors.CodeInternal, ctx, "internal error", "Cannot assign %T to %T", floatval, outputElem.Interface()) } - allErrors := errors.Collection() - + var errs errors.ValidationError for currentRuleSet := v; currentRuleSet != nil; currentRuleSet = currentRuleSet.parent { if currentRuleSet.rule != nil { if err := currentRuleSet.rule.Evaluate(ctx, floatval); err != nil { - allErrors = append(allErrors, err...) + errs = errors.Join(errs, err) } } } - - if len(allErrors) != 0 { - return allErrors - } - return nil + return errs } -// Evaluate performs validation of a RuleSet against a float value and returns a ValidationErrorCollection. -func (v *FloatRuleSet[T]) Evaluate(ctx context.Context, value T) errors.ValidationErrorCollection { +// Evaluate performs validation of a RuleSet against a float value and returns a ValidationError. +func (v *FloatRuleSet[T]) Evaluate(ctx context.Context, value T) errors.ValidationError { var out T return v.Apply(ctx, value, &out) } diff --git a/pkg/rules/float_test.go b/pkg/rules/float_test.go index 1762f2c..87c259e 100644 --- a/pkg/rules/float_test.go +++ b/pkg/rules/float_test.go @@ -41,7 +41,7 @@ func TestFloatRuleSet_Apply_StrictError(t *testing.T) { var out float64 err := rules.Float64().WithStrict().Apply(context.Background(), "123.0", &out) - if len(err) == 0 { + if len(errors.Unwrap(err)) == 0 { t.Error("Expected errors to not be empty") return } @@ -95,7 +95,7 @@ func TestFloatRuleSet_WithRuleFunc(t *testing.T) { WithRuleFunc(testhelpers.NewMockRuleWithErrors[float64](1).Function()). Apply(context.Background(), "123.0", &out) - if len(err) == 0 { + if len(errors.Unwrap(err)) == 0 { t.Error("Expected errors to not be empty") return } @@ -561,7 +561,7 @@ func TestFloatRuleSet_Apply_CoerceFromBool_Strict(t *testing.T) { var out float64 err := rules.Float64().WithStrict().Apply(context.Background(), true, &out) - if len(err) == 0 { + if len(errors.Unwrap(err)) == 0 { t.Error("Expected errors to not be empty") return } diff --git a/pkg/rules/inerface.go b/pkg/rules/inerface.go index 7eca9de..808ed6b 100644 --- a/pkg/rules/inerface.go +++ b/pkg/rules/inerface.go @@ -18,7 +18,7 @@ type InterfaceRuleSet[T any] struct { rule Rule[T] parent *InterfaceRuleSet[T] label string - cast func(ctx context.Context, value any) (T, errors.ValidationErrorCollection) + cast func(ctx context.Context, value any) (T, errors.ValidationError) errorConfig *errors.ErrorConfig } @@ -66,7 +66,7 @@ func interfaceWithErrorConfig[T any](config *errors.ErrorConfig) interfaceCloneO // // A third boolean return value is added to differentiate between a successful cast to a nil value // and -func (v *InterfaceRuleSet[T]) WithCast(fn func(ctx context.Context, value any) (T, errors.ValidationErrorCollection)) *InterfaceRuleSet[T] { +func (v *InterfaceRuleSet[T]) WithCast(fn func(ctx context.Context, value any) (T, errors.ValidationError)) *InterfaceRuleSet[T] { newRuleSet := v.clone(interfaceWithLabel[T]("WithCast()")) newRuleSet.cast = fn return newRuleSet @@ -99,8 +99,8 @@ func (v *InterfaceRuleSet[T]) WithNil() *InterfaceRuleSet[T] { } // Apply performs a validation of a RuleSet against a value and assigns the result to the output parameter. -// It returns a ValidationErrorCollection if any validation errors occur. -func (ruleSet *InterfaceRuleSet[T]) Apply(ctx context.Context, input any, output any) errors.ValidationErrorCollection { +// It returns a ValidationError if any validation errors occur. +func (ruleSet *InterfaceRuleSet[T]) Apply(ctx context.Context, input any, output any) errors.ValidationError { // Add error config to context for error customization ctx = errors.WithErrorConfig(ctx, ruleSet.errorConfig) @@ -112,18 +112,14 @@ func (ruleSet *InterfaceRuleSet[T]) Apply(ctx context.Context, input any, output // Ensure output is a pointer outputVal := reflect.ValueOf(output) if outputVal.Kind() != reflect.Ptr || outputVal.IsNil() { - return errors.Collection(errors.Errorf( - errors.CodeInternal, ctx, "internal error", "Output must be a non-nil pointer", - )) + return errors.Errorf(errors.CodeInternal, ctx, "internal error", "Output must be a non-nil pointer") } // Attempt to cast the input value directly to the expected type T if v, ok := input.(T); ok { inputValue := reflect.ValueOf(v) if !inputValue.Type().AssignableTo(outputVal.Elem().Type()) { - return errors.Collection(errors.Errorf( - errors.CodeInternal, ctx, "internal error", "Cannot assign `%T` to `%T`", input, output, - )) + return errors.Errorf(errors.CodeInternal, ctx, "internal error", "Cannot assign `%T` to `%T`", input, output) } outputVal.Elem().Set(inputValue) return ruleSet.Evaluate(ctx, v) @@ -143,38 +139,27 @@ func (ruleSet *InterfaceRuleSet[T]) Apply(ctx context.Context, input any, output } // If casting fails, return a coercion error - return errors.Collection( - errors.Error(errors.CodeType, - ctx, - reflect.TypeOf(new(T)).Elem().Name(), - reflect.ValueOf(input).Kind().String(), - ), + return errors.Error(errors.CodeType, + ctx, + reflect.TypeOf(new(T)).Elem().Name(), + reflect.ValueOf(input).Kind().String(), ) } // Evaluate performs a validation of a RuleSet against all the defined rules. -func (v *InterfaceRuleSet[T]) Evaluate(ctx context.Context, value T) errors.ValidationErrorCollection { - allErrors := errors.Collection() - +func (v *InterfaceRuleSet[T]) Evaluate(ctx context.Context, value T) errors.ValidationError { + var errs errors.ValidationError currentRuleSet := v ctx = rulecontext.WithRuleSet(ctx, v) - for currentRuleSet != nil { if currentRuleSet.rule != nil { - err := currentRuleSet.rule.Evaluate(ctx, value) - if err != nil { - allErrors = append(allErrors, err...) + if err := currentRuleSet.rule.Evaluate(ctx, value); err != nil { + errs = errors.Join(errs, err) } } - currentRuleSet = currentRuleSet.parent } - - if len(allErrors) != 0 { - return allErrors - } else { - return nil - } + return errs } // WithRule returns a new child rule set that applies a custom validation rule. diff --git a/pkg/rules/int.go b/pkg/rules/int.go index 1466317..4e57640 100644 --- a/pkg/rules/int.go +++ b/pkg/rules/int.go @@ -213,6 +213,7 @@ type conflictTypeReplacesWrapper[T integer] struct { ct intConflictType } +// Replaces returns true if the other rule is an IntRuleSet with a conflicting conflict type. func (w conflictTypeReplacesWrapper[T]) Replaces(r Rule[T]) bool { // Try to cast to IntRuleSet to access conflictType if rs, ok := r.(interface{ getConflictType() intConflictType }); ok { @@ -250,8 +251,8 @@ func (v *IntRuleSet[T]) WithNil() *IntRuleSet[T] { } // Apply performs validation of a RuleSet against a value and assigns the result to the output parameter. -// Apply returns a ValidationErrorCollection if any validation errors occur. -func (ruleSet *IntRuleSet[T]) Apply(ctx context.Context, input any, output any) errors.ValidationErrorCollection { +// Apply returns a ValidationError if any validation errors occur. +func (ruleSet *IntRuleSet[T]) Apply(ctx context.Context, input any, output any) errors.ValidationError { // Add error config to context for error customization ctx = errors.WithErrorConfig(ctx, ruleSet.errorConfig) @@ -263,15 +264,13 @@ func (ruleSet *IntRuleSet[T]) Apply(ctx context.Context, input any, output any) // Ensure output is a non-nil pointer outputVal := reflect.ValueOf(output) if outputVal.Kind() != reflect.Ptr || outputVal.IsNil() { - return errors.Collection(errors.Errorf( - errors.CodeInternal, ctx, "internal error", "Output must be a non-nil pointer", - )) + return errors.Errorf(errors.CodeInternal, ctx, "internal error", "Output must be a non-nil pointer") } // Attempt to coerce the input value to an integer intval, validationErr := ruleSet.coerceInt(input, ctx) if validationErr != nil { - return errors.Collection(validationErr) + return validationErr } // Handle setting the value in output @@ -312,44 +311,31 @@ func (ruleSet *IntRuleSet[T]) Apply(ctx context.Context, input any, output any) // If the types are incompatible, return an error if !assignable { - return errors.Collection(errors.Errorf( - errors.CodeInternal, ctx, "internal error", "Cannot assign %T to %T", intval, outputElem.Interface(), - )) + return errors.Errorf(errors.CodeInternal, ctx, "internal error", "Cannot assign %T to %T", intval, outputElem.Interface()) } - allErrors := errors.Collection() - + var errs errors.ValidationError for currentRuleSet := ruleSet; currentRuleSet != nil; currentRuleSet = currentRuleSet.parent { if currentRuleSet.rule != nil { if err := currentRuleSet.rule.Evaluate(ctx, intval); err != nil { - allErrors = append(allErrors, err...) + errs = errors.Join(errs, err) } } } - - if len(allErrors) != 0 { - return allErrors - } - return nil + return errs } -// Evaluate performs validation of a RuleSet against an integer value and returns a ValidationErrorCollection. -func (v *IntRuleSet[T]) Evaluate(ctx context.Context, value T) errors.ValidationErrorCollection { - allErrors := errors.Collection() - +// Evaluate performs validation of a RuleSet against an integer value and returns a ValidationError. +func (v *IntRuleSet[T]) Evaluate(ctx context.Context, value T) errors.ValidationError { + var errs errors.ValidationError for currentRuleSet := v; currentRuleSet != nil; currentRuleSet = currentRuleSet.parent { if currentRuleSet.rule != nil { if err := currentRuleSet.rule.Evaluate(ctx, value); err != nil { - allErrors = append(allErrors, err...) + errs = errors.Join(errs, err) } } } - - if len(allErrors) != 0 { - return allErrors - } else { - return nil - } + return errs } // noConflict returns the new array rule set with all conflicting rules removed. diff --git a/pkg/rules/int_test.go b/pkg/rules/int_test.go index c208935..f0bdc59 100644 --- a/pkg/rules/int_test.go +++ b/pkg/rules/int_test.go @@ -43,7 +43,7 @@ func TestIntRuleSet_Apply_StrictError(t *testing.T) { var out int err := rules.Int().WithStrict().Apply(context.Background(), "123", &out) - if len(err) == 0 { + if len(errors.Unwrap(err)) == 0 { t.Error("Expected errors to not be empty") return } @@ -101,7 +101,7 @@ func TestIntRuleSet_Apply_CoerceFromHex(t *testing.T) { err = rules.Int().WithBase(16).Apply(context.Background(), "XYZ", &actual) - if len(err) == 0 { + if len(errors.Unwrap(err)) == 0 { t.Error("Expected errors to not be empty") return } @@ -113,7 +113,7 @@ func TestIntRuleSet_Apply_CoerceFromFloatWithError(t *testing.T) { var out int err := rules.Int().Apply(context.Background(), 1.000001, &out) - if len(err) == 0 { + if len(errors.Unwrap(err)) == 0 { t.Error("Expected errors to not be empty") return } @@ -135,7 +135,7 @@ func TestIntRuleSet_WithRuleFunc(t *testing.T) { WithRuleFunc(testhelpers.NewMockRuleWithErrors[int](1).Function()). Apply(context.Background(), "123", &out) - if len(err) == 0 { + if len(errors.Unwrap(err)) == 0 { t.Error("Expected errors to not be empty") return } @@ -418,7 +418,7 @@ func TestIntRuleSet_Apply_CoerceFromBool_Strict(t *testing.T) { var out int err := rules.Int().WithStrict().Apply(context.Background(), true, &out) - if len(err) == 0 { + if len(errors.Unwrap(err)) == 0 { t.Error("Expected errors to not be empty") return } diff --git a/pkg/rules/interface_test.go b/pkg/rules/interface_test.go index 9174086..abf3fa4 100644 --- a/pkg/rules/interface_test.go +++ b/pkg/rules/interface_test.go @@ -124,7 +124,7 @@ func TestInterfaceRuleSet_WithCast(t *testing.T) { testhelpers.MustNotApply(t, ruleSet.Any(), 123, errors.CodeType) - ruleSet = ruleSet.WithCast(func(ctx context.Context, v any) (MyTestInterface, errors.ValidationErrorCollection) { + ruleSet = ruleSet.WithCast(func(ctx context.Context, v any) (MyTestInterface, errors.ValidationError) { if intval, ok := v.(int); ok { return MyTestImplInt(intval), nil } @@ -134,7 +134,7 @@ func TestInterfaceRuleSet_WithCast(t *testing.T) { testhelpers.MustApplyMutation(t, ruleSet.Any(), 123, MyTestImplInt(123)) testhelpers.MustNotApply(t, ruleSet.Any(), "abc", errors.CodeType) - ruleSetWithString := ruleSet.WithCast(func(ctx context.Context, v any) (MyTestInterface, errors.ValidationErrorCollection) { + ruleSetWithString := ruleSet.WithCast(func(ctx context.Context, v any) (MyTestInterface, errors.ValidationError) { if strval, ok := v.(string); ok { return MyTestImplStr(strval), nil } @@ -145,9 +145,9 @@ func TestInterfaceRuleSet_WithCast(t *testing.T) { testhelpers.MustApplyMutation(t, ruleSetWithString.Any(), "abc", MyTestImplStr("abc")) // If a cast returns an error that error is returned - ruleSetWithError := ruleSet.WithCast(func(ctx context.Context, v any) (MyTestInterface, errors.ValidationErrorCollection) { + ruleSetWithError := ruleSet.WithCast(func(ctx context.Context, v any) (MyTestInterface, errors.ValidationError) { if _, ok := v.(string); ok { - return nil, errors.Collection( + return nil, errors.Join( errors.Errorf(errors.CodeUnexpected, ctx, "unexpected", "test"), ) } diff --git a/pkg/rules/knownKeys.go b/pkg/rules/knownKeys.go index 27c4a9f..f80a714 100644 --- a/pkg/rules/knownKeys.go +++ b/pkg/rules/knownKeys.go @@ -42,24 +42,22 @@ func (k *knownKeys[TK]) exists(key TK) bool { } // Check validates if all keys in the provided reflect.Value are known. -// It returns a ValidationErrorCollection with errors for each unexpected key. +// It returns a ValidationError with errors for each unexpected key. // // If allowUnknown is true when creating the object then this always returns an // empty error collection. -func (k *knownKeys[TK]) Check(ctx context.Context, inValue reflect.Value) errors.ValidationErrorCollection { - errs := errors.Collection() - - // If the knownKeys map is not initialized, return an empty error collection. +func (k *knownKeys[TK]) Check(ctx context.Context, inValue reflect.Value) errors.ValidationError { + // If the knownKeys map is not initialized, return nil. if k.keys == nil { - return errs + return nil } - + var errs []error unk := k.Unknown(inValue) for _, key := range unk { subContext := rulecontext.WithPathString(ctx, toPath(key)) errs = append(errs, errors.Error(errors.CodeUnexpected, subContext)) } - return errs + return errors.Join(errs...) } // Unknown returns all the unexpected keys. diff --git a/pkg/rules/net/conflict_type_private_test.go b/pkg/rules/net/conflict_type_private_test.go index c3d00a5..169beaa 100644 --- a/pkg/rules/net/conflict_type_private_test.go +++ b/pkg/rules/net/conflict_type_private_test.go @@ -32,7 +32,7 @@ func TestDomainConflictType_Replaces_WrongType(t *testing.T) { } // Test with a regular rule (not a ruleset) - use RuleFunc which doesn't implement getConflictType - rule := rules.RuleFunc[string](func(ctx context.Context, value string) errors.ValidationErrorCollection { + rule := rules.RuleFunc[string](func(ctx context.Context, value string) errors.ValidationError { return nil }) if checker.Replaces(rule) { @@ -64,7 +64,7 @@ func TestEmailConflictType_Replaces_WrongType(t *testing.T) { } // Test with a regular rule (not a ruleset) - use RuleFunc which doesn't implement getConflictType - rule := rules.RuleFunc[string](func(ctx context.Context, value string) errors.ValidationErrorCollection { + rule := rules.RuleFunc[string](func(ctx context.Context, value string) errors.ValidationError { return nil }) if checker.Replaces(rule) { @@ -96,7 +96,7 @@ func TestURIConflictType_Replaces_WrongType(t *testing.T) { } // Test with a regular rule (not a ruleset) - use RuleFunc which doesn't implement getConflictType - rule := rules.RuleFunc[string](func(ctx context.Context, value string) errors.ValidationErrorCollection { + rule := rules.RuleFunc[string](func(ctx context.Context, value string) errors.ValidationError { return nil }) if checker.Replaces(rule) { diff --git a/pkg/rules/net/domain.go b/pkg/rules/net/domain.go index 3904a29..6c6b992 100644 --- a/pkg/rules/net/domain.go +++ b/pkg/rules/net/domain.go @@ -122,8 +122,8 @@ func (ruleSet *DomainRuleSet) WithNil() *DomainRuleSet { } // Apply performs a validation of a RuleSet against a value and assigns the result to the output parameter. -// It returns a ValidationErrorCollection if any validation errors occur. -func (ruleSet *DomainRuleSet) Apply(ctx context.Context, input any, output any) errors.ValidationErrorCollection { +// It returns a ValidationError if any validation errors occur. +func (ruleSet *DomainRuleSet) Apply(ctx context.Context, input any, output any) errors.ValidationError { // Add error config to context for error customization ctx = errors.WithErrorConfig(ctx, ruleSet.errorConfig) @@ -135,7 +135,7 @@ func (ruleSet *DomainRuleSet) Apply(ctx context.Context, input any, output any) // Attempt to cast the input to a string valueStr, ok := input.(string) if !ok { - return errors.Collection(errors.Error(errors.CodeType, ctx, "string", reflect.ValueOf(input).Kind().String())) + return errors.Error(errors.CodeType, ctx, "string", reflect.ValueOf(input).Kind().String()) } // Perform the validation @@ -147,9 +147,7 @@ func (ruleSet *DomainRuleSet) Apply(ctx context.Context, input any, output any) // Check if the output is a non-nil pointer if outputVal.Kind() != reflect.Ptr || outputVal.IsNil() { - return errors.Collection(errors.Errorf( - errors.CodeInternal, ctx, "internal error", "output must be a non-nil pointer", - )) + return errors.Errorf(errors.CodeInternal, ctx, "internal error", "output must be a non-nil pointer") } // Dereference the pointer to get the actual value that needs to be set @@ -161,9 +159,7 @@ func (ruleSet *DomainRuleSet) Apply(ctx context.Context, input any, output any) case reflect.Interface: outputElem.Set(reflect.ValueOf(valueStr)) default: - return errors.Collection(errors.Errorf( - errors.CodeInternal, ctx, "internal error", "cannot assign string to %T", output, - )) + return errors.Errorf(errors.CodeInternal, ctx, "internal error", "cannot assign string to %T", output) } return nil @@ -171,63 +167,43 @@ func (ruleSet *DomainRuleSet) Apply(ctx context.Context, input any, output any) // validateBasicDomain performs general domain validation that is valid for any and all domains. // This function always returns a collection even if it is empty. -func validateBasicDomain(ctx context.Context, value string) errors.ValidationErrorCollection { - allErrors := errors.Collection() - - // Convert to punycode +func validateBasicDomain(ctx context.Context, value string) errors.ValidationError { + var errs errors.ValidationError punycode, err := idna.ToASCII(value) - if err != nil { - allErrors = append(allErrors, errors.Errorf(errors.CodePattern, ctx, "invalid format", "domain contains invalid characters")) - return allErrors + return errors.Join(errs, errors.Errorf(errors.CodePattern, ctx, "invalid format", "domain contains invalid characters")) } - - // Check total length if len(punycode) >= 256 { - allErrors = append(allErrors, errors.Errorf(errors.CodeMaxLen, ctx, "too long", "domain exceeds maximum length")) - return allErrors + return errors.Join(errs, errors.Errorf(errors.CodeMaxLen, ctx, "too long", "domain exceeds maximum length")) } - - // Each labels should contain only valid characters parts := strings.Split(punycode, ".") - for _, part := range parts { if !domainLabelPattern.MatchString(part) { - allErrors = append(allErrors, errors.Errorf(errors.CodePattern, ctx, "invalid format", "domain segment is invalid")) + errs = errors.Join(errs, errors.Errorf(errors.CodePattern, ctx, "invalid format", "domain segment is invalid")) break } } - - return allErrors + return errs } // Evaluate performs a validation of a RuleSet against a string and returns an object value of the -// same type or a ValidationErrorCollection. -func (ruleSet *DomainRuleSet) Evaluate(ctx context.Context, value string) errors.ValidationErrorCollection { - allErrors := validateBasicDomain(ctx, value) - - if len(allErrors) > 0 { - return allErrors +// same type or a ValidationError. +func (ruleSet *DomainRuleSet) Evaluate(ctx context.Context, value string) errors.ValidationError { + var errs errors.ValidationError + if ev := validateBasicDomain(ctx, value); ev != nil { + errs = errors.Join(errs, ev) } - currentRuleSet := ruleSet ctx = rulecontext.WithRuleSet(ctx, ruleSet) - for currentRuleSet != nil { if currentRuleSet.rule != nil { - if errs := currentRuleSet.rule.Evaluate(ctx, value); errs != nil { - allErrors = append(allErrors, errs...) + if e := currentRuleSet.rule.Evaluate(ctx, value); e != nil { + errs = errors.Join(errs, e) } } - currentRuleSet = currentRuleSet.parent } - - if len(allErrors) > 0 { - return allErrors - } else { - return nil - } + return errs } // noConflict returns the new array rule set with all conflicting rules removed. diff --git a/pkg/rules/net/email.go b/pkg/rules/net/email.go index 1c60d61..0039f60 100644 --- a/pkg/rules/net/email.go +++ b/pkg/rules/net/email.go @@ -119,8 +119,8 @@ func (ruleSet *EmailRuleSet) WithNil() *EmailRuleSet { } // Apply performs a validation of a RuleSet against a value and assigns the result to the output parameter. -// It returns a ValidationErrorCollection if any validation errors occur. -func (ruleSet *EmailRuleSet) Apply(ctx context.Context, input any, output any) errors.ValidationErrorCollection { +// It returns a ValidationError if any validation errors occur. +func (ruleSet *EmailRuleSet) Apply(ctx context.Context, input any, output any) errors.ValidationError { // Add error config to context for error customization ctx = errors.WithErrorConfig(ctx, ruleSet.errorConfig) @@ -132,7 +132,7 @@ func (ruleSet *EmailRuleSet) Apply(ctx context.Context, input any, output any) e // Attempt to cast the input to a string valueStr, ok := input.(string) if !ok { - return errors.Collection(errors.Error(errors.CodeType, ctx, "string", reflect.ValueOf(input).Kind().String())) + return errors.Error(errors.CodeType, ctx, "string", reflect.ValueOf(input).Kind().String()) } // Perform the validation @@ -144,9 +144,7 @@ func (ruleSet *EmailRuleSet) Apply(ctx context.Context, input any, output any) e // Check if the output is a non-nil pointer if outputVal.Kind() != reflect.Ptr || outputVal.IsNil() { - return errors.Collection(errors.Errorf( - errors.CodeInternal, ctx, "internal error", "output must be a non-nil pointer", - )) + return errors.Errorf(errors.CodeInternal, ctx, "internal error", "output must be a non-nil pointer") } // Dereference the pointer to get the actual value that needs to be set @@ -158,9 +156,7 @@ func (ruleSet *EmailRuleSet) Apply(ctx context.Context, input any, output any) e case reflect.Interface: outputElem.Set(reflect.ValueOf(valueStr)) default: - return errors.Collection(errors.Errorf( - errors.CodeInternal, ctx, "internal error", "cannot assign string to %T", output, - )) + return errors.Errorf(errors.CodeInternal, ctx, "internal error", "cannot assign string to %T", output) } return nil @@ -168,82 +164,57 @@ func (ruleSet *EmailRuleSet) Apply(ctx context.Context, input any, output any) e // validateBasicEmail performs general domain validation that is valid for any and all domains. // This function always returns a collection even if it is empty. -func (ruleSet *EmailRuleSet) validateBasicEmail(ctx context.Context, value string) errors.ValidationErrorCollection { - allErrors := errors.Collection() - +func (ruleSet *EmailRuleSet) validateBasicEmail(ctx context.Context, value string) errors.ValidationError { + var errs errors.ValidationError parts := strings.Split(value, "@") - if len(parts) < 2 { - allErrors = append(allErrors, errors.Errorf(errors.CodePattern, ctx, "invalid format", "missing @ symbol")) - return allErrors + return errors.Join(errs, errors.Errorf(errors.CodePattern, ctx, "invalid format", "missing @ symbol")) } if len(parts) > 2 { - allErrors = append(allErrors, errors.Errorf(errors.CodePattern, ctx, "invalid format", "too many @ symbols")) - return allErrors + return errors.Join(errs, errors.Errorf(errors.CodePattern, ctx, "invalid format", "too many @ symbols")) } - local := parts[0] domain := parts[1] - domainRuleSet := ruleSet.domainRuleSet if domainRuleSet == nil { domainRuleSet = Domain().WithTLD() } - - domainErrs := domainRuleSet.Evaluate(ctx, domain) - - if len(domainErrs) > 0 { - allErrors = append(allErrors, domainErrs...) + if ev := domainRuleSet.Evaluate(ctx, domain); ev != nil { + errs = errors.Join(errs, ev) } - if len(local) == 0 { - allErrors = append(allErrors, errors.Errorf(errors.CodePattern, ctx, "invalid format", "local part is empty")) - return allErrors + return errors.Join(errs, errors.Errorf(errors.CodePattern, ctx, "invalid format", "local part is empty")) } - if strings.HasPrefix(local, ".") { - allErrors = append(allErrors, errors.Errorf(errors.CodePattern, ctx, "invalid format", "cannot start with a dot")) + errs = errors.Join(errs, errors.Errorf(errors.CodePattern, ctx, "invalid format", "cannot start with a dot")) } - if strings.HasSuffix(local, ".") { - allErrors = append(allErrors, errors.Errorf(errors.CodePattern, ctx, "invalid format", "cannot end with a dot")) + errs = errors.Join(errs, errors.Errorf(errors.CodePattern, ctx, "invalid format", "cannot end with a dot")) } - if strings.Contains(local, "..") { - allErrors = append(allErrors, errors.Errorf(errors.CodePattern, ctx, "invalid format", "cannot contain consecutive dots")) + errs = errors.Join(errs, errors.Errorf(errors.CodePattern, ctx, "invalid format", "cannot contain consecutive dots")) } - - return allErrors + return errs } // Evaluate performs a validation of a RuleSet against a string and returns an object value of the -// same type or a ValidationErrorCollection. -func (ruleSet *EmailRuleSet) Evaluate(ctx context.Context, value string) errors.ValidationErrorCollection { - - allErrors := ruleSet.validateBasicEmail(ctx, value) - - if len(allErrors) > 0 { - return allErrors +// same type or a ValidationError. +func (ruleSet *EmailRuleSet) Evaluate(ctx context.Context, value string) errors.ValidationError { + var errs errors.ValidationError + if ev := ruleSet.validateBasicEmail(ctx, value); ev != nil { + errs = errors.Join(errs, ev) } - currentRuleSet := ruleSet ctx = rulecontext.WithRuleSet(ctx, ruleSet) - for currentRuleSet != nil { if currentRuleSet.rule != nil { - if errs := currentRuleSet.rule.Evaluate(ctx, value); errs != nil { - allErrors = append(allErrors, errs...) + if e := currentRuleSet.rule.Evaluate(ctx, value); e != nil { + errs = errors.Join(errs, e) } } - currentRuleSet = currentRuleSet.parent } - - if len(allErrors) > 0 { - return allErrors - } else { - return nil - } + return errs } // WithDomain returns a new child rule set that uses a custom domain validator diff --git a/pkg/rules/net/email_test.go b/pkg/rules/net/email_test.go index c05cd4f..2058277 100644 --- a/pkg/rules/net/email_test.go +++ b/pkg/rules/net/email_test.go @@ -187,7 +187,7 @@ func TestEmailRuleSet_DomainContext(t *testing.T) { if err == nil { t.Error("Expected error to not be nil") - } else if s := err.First().Path(); s != expected { + } else if s := err.Path(); s != expected { t.Errorf("Expected path to be %s, got: %s", expected, s) } } diff --git a/pkg/rules/net/ip.go b/pkg/rules/net/ip.go index e517592..546faeb 100644 --- a/pkg/rules/net/ip.go +++ b/pkg/rules/net/ip.go @@ -116,51 +116,37 @@ func (ruleSet *IPRuleSet) WithNil() *IPRuleSet { } // parseIP attempts to parse the input as either a string or net.IP and returns a net.IP. -func parseIP(ctx context.Context, input any) (net.IP, errors.ValidationErrorCollection) { - // Try to cast directly to net.IP +func parseIP(ctx context.Context, input any) (net.IP, errors.ValidationError) { if ip, ok := input.(net.IP); ok { if ip == nil { - return nil, errors.Collection(errors.Error(errors.CodeNull, ctx)) + return nil, errors.Error(errors.CodeNull, ctx) } return ip, nil } - - // Try to cast to string if str, ok := input.(string); ok { ip := net.ParseIP(str) if ip == nil { - return nil, errors.Collection(errors.Errorf( - errors.CodePattern, ctx, "invalid format", "invalid IP address format", - )) + return nil, errors.Errorf(errors.CodePattern, ctx, "invalid format", "invalid IP address format") } return ip, nil } - - // Try to cast to *string if strPtr, ok := input.(*string); ok && strPtr != nil { ip := net.ParseIP(*strPtr) if ip == nil { - return nil, errors.Collection(errors.Errorf( - errors.CodePattern, ctx, "invalid format", "invalid IP address format", - )) + return nil, errors.Errorf(errors.CodePattern, ctx, "invalid format", "invalid IP address format") } return ip, nil } - - return nil, errors.Collection(errors.Error( - errors.CodeType, ctx, "string or net.IP", reflect.ValueOf(input).Kind().String(), - )) + return nil, errors.Error(errors.CodeType, ctx, "string or net.IP", reflect.ValueOf(input).Kind().String()) } // setOutput sets the output value to the given IP address. -func setOutput(ctx context.Context, output any, ip net.IP) errors.ValidationErrorCollection { +func setOutput(ctx context.Context, output any, ip net.IP) errors.ValidationError { outputVal := reflect.ValueOf(output) // Check if the output is a non-nil pointer if outputVal.Kind() != reflect.Ptr || outputVal.IsNil() { - return errors.Collection(errors.Errorf( - errors.CodeInternal, ctx, "internal error", "output must be a non-nil pointer", - )) + return errors.Errorf(errors.CodeInternal, ctx, "internal error", "output must be a non-nil pointer") } // Dereference the pointer to get the actual value that needs to be set @@ -180,18 +166,16 @@ func setOutput(ctx context.Context, output any, ip net.IP) errors.ValidationErro // Set as net.IP for interface types outputElem.Set(reflect.ValueOf(ip)) default: - return errors.Collection(errors.Errorf( - errors.CodeInternal, ctx, "internal error", "cannot assign IP to %T", output, - )) + return errors.Errorf(errors.CodeInternal, ctx, "internal error", "cannot assign IP to %T", output) } return nil } // Apply performs a validation of a RuleSet against a value and assigns the result to the output parameter. -// It returns a ValidationErrorCollection if any validation errors occur. +// It returns a ValidationError if any validation errors occur. // Input can be either a string or net.IP, and output can be either *string or *net.IP. -func (ruleSet *IPRuleSet) Apply(ctx context.Context, input any, output any) errors.ValidationErrorCollection { +func (ruleSet *IPRuleSet) Apply(ctx context.Context, input any, output any) errors.ValidationError { // Add error config to context for error customization ctx = errors.WithErrorConfig(ctx, ruleSet.errorConfig) @@ -217,39 +201,30 @@ func (ruleSet *IPRuleSet) Apply(ctx context.Context, input any, output any) erro // validateBasicIP performs general IP validation that is valid for any and all IP addresses. // This function always returns a collection even if it is empty. -func validateBasicIP(ctx context.Context, ip net.IP) errors.ValidationErrorCollection { +func validateBasicIP(ctx context.Context, ip net.IP) errors.ValidationError { if ip == nil { - return errors.Collection(errors.Error(errors.CodeNull, ctx)) + return errors.Error(errors.CodeNull, ctx) } return nil } -// Evaluate performs a validation of a RuleSet against a net.IP and returns a ValidationErrorCollection. -func (ruleSet *IPRuleSet) Evaluate(ctx context.Context, ip net.IP) errors.ValidationErrorCollection { - allErrors := validateBasicIP(ctx, ip) - - if len(allErrors) > 0 { - return allErrors +// Evaluate performs a validation of a RuleSet against a net.IP and returns a ValidationError. +func (ruleSet *IPRuleSet) Evaluate(ctx context.Context, ip net.IP) errors.ValidationError { + var errs errors.ValidationError + if err := validateBasicIP(ctx, ip); err != nil { + errs = errors.Join(errs, err) } - currentRuleSet := ruleSet ctx = rulecontext.WithRuleSet(ctx, ruleSet) - for currentRuleSet != nil { if currentRuleSet.rule != nil { - if errs := currentRuleSet.rule.Evaluate(ctx, ip); errs != nil { - allErrors = append(allErrors, errs...) + if e := currentRuleSet.rule.Evaluate(ctx, ip); e != nil { + errs = errors.Join(errs, e) } } - currentRuleSet = currentRuleSet.parent } - - if len(allErrors) > 0 { - return allErrors - } else { - return nil - } + return errs } // noConflict returns the new rule set with all conflicting rules removed. diff --git a/pkg/rules/net/query.go b/pkg/rules/net/query.go index 72df14f..74f9516 100644 --- a/pkg/rules/net/query.go +++ b/pkg/rules/net/query.go @@ -12,7 +12,7 @@ import ( ) // queryPercentEncodingRule validates that the query string is properly percent-encoded. -func queryPercentEncodingRule(ctx context.Context, value string) errors.ValidationErrorCollection { +func queryPercentEncodingRule(ctx context.Context, value string) errors.ValidationError { runes := []rune(value) l := len(runes) for i := range runes { @@ -20,9 +20,7 @@ func queryPercentEncodingRule(ctx context.Context, value string) errors.Validati continue } if i >= l-2 || !isHex(runes[i+1]) || !isHex(runes[i+2]) { - return errors.Collection( - errors.Errorf(errors.CodeEncoding, ctx, "invalid encoding", "value is not properly URI encoded"), - ) + return errors.Errorf(errors.CodeEncoding, ctx, "invalid encoding", "value is not properly URI encoded") } } return nil @@ -135,14 +133,14 @@ func (q *QueryRuleSet) clone(options ...queryCloneOption) *QueryRuleSet { var defaultQueryStringRuleSet rules.RuleSet[string] = rules.String().WithRuleFunc(queryPercentEncodingRule) // Evaluate validates the query (percent encoding on the encoded form), registered parameters, and top-level rules. -func (q *QueryRuleSet) Evaluate(ctx context.Context, values url.Values) errors.ValidationErrorCollection { +func (q *QueryRuleSet) Evaluate(ctx context.Context, values url.Values) errors.ValidationError { queryStringForEncoding := values.Encode() if queryStringForEncoding != "" { if err := defaultQueryStringRuleSet.Evaluate(ctx, queryStringForEncoding); err != nil { return err } } - allErrors := errors.Collection() + var errs errors.ValidationError if len(q.paramRules) > 0 { for name, spec := range q.paramRules { if spec == nil { @@ -155,38 +153,34 @@ func (q *QueryRuleSet) Evaluate(ctx context.Context, values url.Values) errors.V paramVal = paramValues[0] } paramContext := rulecontext.WithPathString(ctx, "query["+name+"]") - if spec.ruleSet != nil { if !paramPresent && spec.ruleSet.Required() { - allErrors = append(allErrors, errors.Errorf(errors.CodeRequired, paramContext, "required", "query parameter %q is required", name)) + errs = errors.Join(errs, errors.Errorf(errors.CodeRequired, paramContext, "required", "query parameter %q is required", name)) continue } if !paramPresent && !spec.ruleSet.Required() { continue } if err := spec.ruleSet.Evaluate(paramContext, any(paramVal)); err != nil { - allErrors = append(allErrors, err...) + errs = errors.Join(errs, err) } } } - if len(allErrors) > 0 { - return allErrors + if errs != nil { + return errs } } current := q ctx = rulecontext.WithRuleSet(ctx, q) for current != nil { if current.rule != nil { - if errs := current.rule.Evaluate(ctx, values); errs != nil { - allErrors = append(allErrors, errs...) + if e := current.rule.Evaluate(ctx, values); e != nil { + errs = errors.Join(errs, e) } } current = current.parent } - if len(allErrors) > 0 { - return allErrors - } - return nil + return errs } // queryParser is used by Apply to parse a query string; tests may override to trigger the parse-error branch. @@ -194,7 +188,7 @@ var queryParser = url.ParseQuery // Apply coerces input to url.Values (string is parsed; parse error becomes a validation error), validates, and writes to output. // Output may be *string, *url.Values, or *any. -func (q *QueryRuleSet) Apply(ctx context.Context, input any, output any) errors.ValidationErrorCollection { +func (q *QueryRuleSet) Apply(ctx context.Context, input any, output any) errors.ValidationError { ctx = errors.WithErrorConfig(ctx, q.errorConfig) var values url.Values @@ -203,16 +197,12 @@ func (q *QueryRuleSet) Apply(ctx context.Context, input any, output any) errors. var parseErr error values, parseErr = queryParser(v) if parseErr != nil { - return errors.Collection( - errors.Errorf(errors.CodeEncoding, ctx, "invalid query", "query string could not be parsed: %v", parseErr), - ) + return errors.Errorf(errors.CodeEncoding, ctx, "invalid query", "query string could not be parsed: %v", parseErr) } case url.Values: values = v default: - return errors.Collection(errors.Errorf( - errors.CodeType, ctx, "string or url.Values", reflect.ValueOf(input).Kind().String(), - )) + return errors.Errorf(errors.CodeType, ctx, "string or url.Values", reflect.ValueOf(input).Kind().String()) } if err := q.Evaluate(ctx, values); err != nil { @@ -221,9 +211,7 @@ func (q *QueryRuleSet) Apply(ctx context.Context, input any, output any) errors. outputVal := reflect.ValueOf(output) if outputVal.Kind() != reflect.Ptr || outputVal.IsNil() { - return errors.Collection(errors.Errorf( - errors.CodeInternal, ctx, "internal error", "output must be a non-nil pointer", - )) + return errors.Errorf(errors.CodeInternal, ctx, "internal error", "output must be a non-nil pointer") } elem := outputVal.Elem() @@ -245,9 +233,7 @@ func (q *QueryRuleSet) Apply(ctx context.Context, input any, output any) errors. return nil } } - return errors.Collection(errors.Errorf( - errors.CodeInternal, ctx, "internal error", "query output must be *string, *url.Values, or *any, got %T", output, - )) + return errors.Errorf(errors.CodeInternal, ctx, "internal error", "query output must be *string, *url.Values, or *any, got %T", output) } // String returns a string representation of the rule set for debugging. diff --git a/pkg/rules/net/query_private_test.go b/pkg/rules/net/query_private_test.go index dd7eef6..db39de0 100644 --- a/pkg/rules/net/query_private_test.go +++ b/pkg/rules/net/query_private_test.go @@ -39,8 +39,8 @@ func TestQueryPercentEncodingRule_InvalidEncoding(t *testing.T) { t.Error("expected encoding error, got nil") return } - if err.First().Code() != validateerrors.CodeEncoding { - t.Errorf("expected CodeEncoding, got %s", err.First().Code()) + if err.Code() != validateerrors.CodeEncoding { + t.Errorf("expected CodeEncoding, got %s", err.Code()) } } else { if err != nil { @@ -93,8 +93,8 @@ func TestQueryRuleSet_Apply_ParseError(t *testing.T) { if err == nil { t.Fatal("expected error when queryParser fails") } - if err.First().Code() != validateerrors.CodeEncoding { - t.Errorf("expected CodeEncoding, got %s", err.First().Code()) + if err.Code() != validateerrors.CodeEncoding { + t.Errorf("expected CodeEncoding, got %s", err.Code()) } } @@ -120,32 +120,32 @@ func TestQueryRuleSet_Evaluate_EncodingCheckReturnsError(t *testing.T) { ctx := context.Background() saved := defaultQueryStringRuleSet defer func() { defaultQueryStringRuleSet = saved }() - defaultQueryStringRuleSet = rules.String().WithRuleFunc(func(context.Context, string) validateerrors.ValidationErrorCollection { - return validateerrors.Collection(validateerrors.Errorf(validateerrors.CodeEncoding, ctx, "bad", "injected")) + defaultQueryStringRuleSet = rules.String().WithRuleFunc(func(context.Context, string) validateerrors.ValidationError { + return validateerrors.Errorf(validateerrors.CodeEncoding, ctx, "bad", "injected") }) q := Query() err := q.Evaluate(ctx, url.Values{"x": {"y"}}) if err == nil { t.Fatal("expected error from injected encoding rule") } - if err.First().Code() != validateerrors.CodeEncoding { - t.Errorf("expected CodeEncoding, got %s", err.First().Code()) + if err.Code() != validateerrors.CodeEncoding { + t.Errorf("expected CodeEncoding, got %s", err.Code()) } } // TestQueryRuleSet_Evaluate_ParamRuleSetReturnsError covers the branch where spec.ruleSet.Evaluate returns errors (append to allErrors). func TestQueryRuleSet_Evaluate_ParamRuleSetReturnsError(t *testing.T) { ctx := context.Background() - failRule := rules.String().WithRuleFunc(func(context.Context, string) validateerrors.ValidationErrorCollection { - return validateerrors.Collection(validateerrors.Errorf(validateerrors.CodePattern, ctx, "bad", "injected")) + failRule := rules.String().WithRuleFunc(func(context.Context, string) validateerrors.ValidationError { + return validateerrors.Errorf(validateerrors.CodePattern, ctx, "bad", "injected") }).Any() q := Query().WithParam("x", failRule) err := q.Evaluate(ctx, url.Values{"x": {"y"}}) if err == nil { t.Fatal("expected error from param rule") } - if err.First().Code() != validateerrors.CodePattern { - t.Errorf("expected CodePattern, got %s", err.First().Code()) + if err.Code() != validateerrors.CodePattern { + t.Errorf("expected CodePattern, got %s", err.Code()) } } diff --git a/pkg/rules/net/query_test.go b/pkg/rules/net/query_test.go index 4e0573a..3f09331 100644 --- a/pkg/rules/net/query_test.go +++ b/pkg/rules/net/query_test.go @@ -52,8 +52,8 @@ func TestQueryRuleSet_WithParam(t *testing.T) { if err == nil { t.Fatal("expected error when required param missing") } - if err.First().Code() != errors.CodeRequired { - t.Errorf("expected CodeRequired, got %s", err.First().Code()) + if err.Code() != errors.CodeRequired { + t.Errorf("expected CodeRequired, got %s", err.Code()) } } @@ -81,9 +81,9 @@ func TestQueryRuleSet_Required(t *testing.T) { func TestQueryRuleSet_WithRule_WithRuleFunc(t *testing.T) { ctx := context.Background() // WithRuleFunc: custom rule that fails when query has "forbidden=1" - rs := net.Query().WithRuleFunc(func(ctx context.Context, v url.Values) errors.ValidationErrorCollection { + rs := net.Query().WithRuleFunc(func(ctx context.Context, v url.Values) errors.ValidationError { if v.Get("forbidden") == "1" { - return errors.Collection(errors.Errorf(errors.CodeForbidden, ctx, "forbidden", "forbidden param present")) + return errors.Join(errors.Errorf(errors.CodeForbidden, ctx, "forbidden", "forbidden param present")) } return nil }) @@ -96,13 +96,13 @@ func TestQueryRuleSet_WithRule_WithRuleFunc(t *testing.T) { if err == nil { t.Fatal("expected error when custom rule fails") } - if err.First().Code() != errors.CodeForbidden { - t.Errorf("expected CodeForbidden, got %s", err.First().Code()) + if err.Code() != errors.CodeForbidden { + t.Errorf("expected CodeForbidden, got %s", err.Code()) } // WithRule: pass RuleFunc as Rule (RuleFunc implements Rule) - rs2 := net.Query().WithRule(rules.RuleFunc[url.Values](func(ctx context.Context, v url.Values) errors.ValidationErrorCollection { + rs2 := net.Query().WithRule(rules.RuleFunc[url.Values](func(ctx context.Context, v url.Values) errors.ValidationError { if len(v) == 0 { - return errors.Collection(errors.Errorf(errors.CodeRequired, ctx, "empty", "query must not be empty")) + return errors.Join(errors.Errorf(errors.CodeRequired, ctx, "empty", "query must not be empty")) } return nil })) @@ -126,8 +126,8 @@ func TestQueryRuleSet_Apply_inputOutputBranches(t *testing.T) { if err == nil { t.Fatal("expected error for non-string/url.Values input") } - if err.First().Code() != errors.CodeType { - t.Errorf("expected CodeType, got %s", err.First().Code()) + if err.Code() != errors.CodeType { + t.Errorf("expected CodeType, got %s", err.Code()) } // Nil output pointer @@ -135,8 +135,8 @@ func TestQueryRuleSet_Apply_inputOutputBranches(t *testing.T) { if err == nil { t.Fatal("expected error for nil output") } - if err.First().Code() != errors.CodeInternal { - t.Errorf("expected CodeInternal for nil output, got %s", err.First().Code()) + if err.Code() != errors.CodeInternal { + t.Errorf("expected CodeInternal for nil output, got %s", err.Code()) } // Non-pointer output @@ -172,8 +172,8 @@ func TestQueryRuleSet_Apply_inputOutputBranches(t *testing.T) { if err == nil { t.Fatal("expected error for invalid output type") } - if err.First().Code() != errors.CodeInternal { - t.Errorf("expected CodeInternal for wrong output type, got %s", err.First().Code()) + if err.Code() != errors.CodeInternal { + t.Errorf("expected CodeInternal for wrong output type, got %s", err.Code()) } } @@ -187,8 +187,8 @@ func TestQueryRuleSet_Apply_parseError(t *testing.T) { // ParseQuery may or may not fail depending on Go version; if it fails we get CodeEncoding return } - if err.First().Code() != errors.CodeEncoding { - t.Errorf("expected CodeEncoding on parse error, got %s", err.First().Code()) + if err.Code() != errors.CodeEncoding { + t.Errorf("expected CodeEncoding on parse error, got %s", err.Code()) } } diff --git a/pkg/rules/net/rule_domain_suffix.go b/pkg/rules/net/rule_domain_suffix.go index e8fc7e8..afc56fd 100644 --- a/pkg/rules/net/rule_domain_suffix.go +++ b/pkg/rules/net/rule_domain_suffix.go @@ -19,7 +19,7 @@ type domainSuffixRule struct { } // Evaluate takes a context and string value and returns an error if it does not appear to be a valid domain. -func (rule *domainSuffixRule) Evaluate(ctx context.Context, value string) errors.ValidationErrorCollection { +func (rule *domainSuffixRule) Evaluate(ctx context.Context, value string) errors.ValidationError { // Convert to punycode punycode, _ := idna.ToASCII(value) @@ -32,9 +32,7 @@ func (rule *domainSuffixRule) Evaluate(ctx context.Context, value string) errors } } - return errors.Collection( - errors.Errorf(errors.CodePattern, ctx, "invalid format", "domain suffix is not valid"), - ) + return errors.Errorf(errors.CodePattern, ctx, "invalid format", "domain suffix is not valid") } // Replaces returns true for any suffix rule. diff --git a/pkg/rules/net/rule_ip_cidr.go b/pkg/rules/net/rule_ip_cidr.go index c2ea19d..7d4b8ec 100644 --- a/pkg/rules/net/rule_ip_cidr.go +++ b/pkg/rules/net/rule_ip_cidr.go @@ -14,7 +14,7 @@ type ipCIDRRule struct { } // Evaluate validates that the IP address is within one of the allowed CIDR blocks. -func (rule *ipCIDRRule) Evaluate(ctx context.Context, ip net.IP) errors.ValidationErrorCollection { +func (rule *ipCIDRRule) Evaluate(ctx context.Context, ip net.IP) errors.ValidationError { if ip == nil { return nil } @@ -25,9 +25,7 @@ func (rule *ipCIDRRule) Evaluate(ctx context.Context, ip net.IP) errors.Validati } } - return errors.Collection(errors.Errorf( - errors.CodePattern, ctx, "invalid format", "IP address is not within the allowed CIDR block(s)", - )) + return errors.Errorf(errors.CodePattern, ctx, "invalid format", "IP address is not within the allowed CIDR block(s)") } // Replaces returns true for any CIDR rule. diff --git a/pkg/rules/net/rule_ip_public_private.go b/pkg/rules/net/rule_ip_public_private.go index b9ba746..51ee2a5 100644 --- a/pkg/rules/net/rule_ip_public_private.go +++ b/pkg/rules/net/rule_ip_public_private.go @@ -42,7 +42,7 @@ type ipPublicPrivateRule struct { } // Evaluate validates that the IP address matches the public/private requirement. -func (rule *ipPublicPrivateRule) Evaluate(ctx context.Context, ip net.IP) errors.ValidationErrorCollection { +func (rule *ipPublicPrivateRule) Evaluate(ctx context.Context, ip net.IP) errors.ValidationError { if ip == nil { return nil } @@ -50,15 +50,10 @@ func (rule *ipPublicPrivateRule) Evaluate(ctx context.Context, ip net.IP) errors isPrivate := isPrivateIP(ip) if rule.publicOnly && isPrivate { - return errors.Collection(errors.Errorf( - errors.CodePattern, ctx, "invalid format", "private IP addresses are not allowed", - )) + return errors.Errorf(errors.CodePattern, ctx, "invalid format", "private IP addresses are not allowed") } - if rule.privateOnly && !isPrivate { - return errors.Collection(errors.Errorf( - errors.CodePattern, ctx, "invalid format", "public IP addresses are not allowed", - )) + return errors.Errorf(errors.CodePattern, ctx, "invalid format", "public IP addresses are not allowed") } return nil diff --git a/pkg/rules/net/rule_ip_range.go b/pkg/rules/net/rule_ip_range.go index 4d6c454..09c4349 100644 --- a/pkg/rules/net/rule_ip_range.go +++ b/pkg/rules/net/rule_ip_range.go @@ -15,16 +15,14 @@ type ipRangeRule struct { } // Evaluate validates that the IP address is within the specified range (inclusive). -func (rule *ipRangeRule) Evaluate(ctx context.Context, ip net.IP) errors.ValidationErrorCollection { +func (rule *ipRangeRule) Evaluate(ctx context.Context, ip net.IP) errors.ValidationError { if ip == nil { return nil } // Compare IPs byte by byte if compareIPs(ip, rule.startIP) < 0 || compareIPs(ip, rule.endIP) > 0 { - return errors.Collection(errors.Errorf( - errors.CodePattern, ctx, "invalid format", "IP address is not within the allowed range", - )) + return errors.Errorf(errors.CodePattern, ctx, "invalid format", "IP address is not within the allowed range") } return nil diff --git a/pkg/rules/net/rule_ip_subnet_mask.go b/pkg/rules/net/rule_ip_subnet_mask.go index 3a29f31..c7ecb12 100644 --- a/pkg/rules/net/rule_ip_subnet_mask.go +++ b/pkg/rules/net/rule_ip_subnet_mask.go @@ -15,7 +15,7 @@ type ipSubnetMaskRule struct { } // Evaluate validates that the IP address is within the network defined by the network address and subnet mask. -func (rule *ipSubnetMaskRule) Evaluate(ctx context.Context, ip net.IP) errors.ValidationErrorCollection { +func (rule *ipSubnetMaskRule) Evaluate(ctx context.Context, ip net.IP) errors.ValidationError { if ip == nil { return nil } @@ -27,9 +27,7 @@ func (rule *ipSubnetMaskRule) Evaluate(ctx context.Context, ip net.IP) errors.Va } if !network.Contains(ip) { - return errors.Collection(errors.Errorf( - errors.CodePattern, ctx, "invalid format", "IP address is not within the specified network", - )) + return errors.Errorf(errors.CodePattern, ctx, "invalid format", "IP address is not within the specified network") } return nil diff --git a/pkg/rules/net/rule_ip_version.go b/pkg/rules/net/rule_ip_version.go index 058c0b4..97b64b2 100644 --- a/pkg/rules/net/rule_ip_version.go +++ b/pkg/rules/net/rule_ip_version.go @@ -15,7 +15,7 @@ type ipVersionRule struct { } // Evaluate validates that the IP address matches the allowed IP version(s). -func (rule *ipVersionRule) Evaluate(ctx context.Context, ip net.IP) errors.ValidationErrorCollection { +func (rule *ipVersionRule) Evaluate(ctx context.Context, ip net.IP) errors.ValidationError { if ip == nil { return nil } @@ -24,15 +24,10 @@ func (rule *ipVersionRule) Evaluate(ctx context.Context, ip net.IP) errors.Valid isIPv6 := !isIPv4 && ip.To16() != nil if isIPv4 && !rule.allowIPv4 { - return errors.Collection(errors.Errorf( - errors.CodePattern, ctx, "invalid format", "IPv4 addresses are not allowed", - )) + return errors.Errorf(errors.CodePattern, ctx, "invalid format", "IPv4 addresses are not allowed") } - if isIPv6 && !rule.allowIPv6 { - return errors.Collection(errors.Errorf( - errors.CodePattern, ctx, "invalid format", "IPv6 addresses are not allowed", - )) + return errors.Errorf(errors.CodePattern, ctx, "invalid format", "IPv6 addresses are not allowed") } return nil diff --git a/pkg/rules/net/uri.go b/pkg/rules/net/uri.go index c6121a0..3e37795 100644 --- a/pkg/rules/net/uri.go +++ b/pkg/rules/net/uri.go @@ -35,7 +35,7 @@ func isHex(c rune) bool { return (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') || (c >= '0' && c <= '9') } -func percentEncodingRule(ctx context.Context, value string) errors.ValidationErrorCollection { +func percentEncodingRule(ctx context.Context, value string) errors.ValidationError { runes := []rune(value) l := len(runes) @@ -45,9 +45,7 @@ func percentEncodingRule(ctx context.Context, value string) errors.ValidationErr } if i >= l-2 || !isHex(runes[i+1]) || !isHex(runes[i+2]) { - return errors.Collection( - errors.Errorf(errors.CodeEncoding, ctx, "invalid encoding", "value is not properly URI encoded"), - ) + return errors.Errorf(errors.CodeEncoding, ctx, "invalid encoding", "value is not properly URI encoded") } } @@ -299,8 +297,8 @@ func (ruleSet *URIRuleSet) WithRelative() *URIRuleSet { } // Apply performs a validation of a RuleSet against a value and assigns the result to the output parameter. -// It returns a ValidationErrorCollection if any validation errors occur. -func (ruleSet *URIRuleSet) Apply(ctx context.Context, input any, output any) errors.ValidationErrorCollection { +// It returns a ValidationError if any validation errors occur. +func (ruleSet *URIRuleSet) Apply(ctx context.Context, input any, output any) errors.ValidationError { // Add error config to context for error customization ctx = errors.WithErrorConfig(ctx, ruleSet.errorConfig) @@ -312,7 +310,7 @@ func (ruleSet *URIRuleSet) Apply(ctx context.Context, input any, output any) err // Attempt to cast the input to a string valueStr, ok := input.(string) if !ok { - return errors.Collection(errors.Error(errors.CodeType, ctx, "string", reflect.ValueOf(input).Kind().String())) + return errors.Error(errors.CodeType, ctx, "string", reflect.ValueOf(input).Kind().String()) } // Perform the validation @@ -324,9 +322,7 @@ func (ruleSet *URIRuleSet) Apply(ctx context.Context, input any, output any) err // Check if the output is a non-nil pointer if outputVal.Kind() != reflect.Ptr || outputVal.IsNil() { - return errors.Collection(errors.Errorf( - errors.CodeInternal, ctx, "internal error", "output must be a non-nil pointer", - )) + return errors.Errorf(errors.CodeInternal, ctx, "internal error", "output must be a non-nil pointer") } // Dereference the pointer to get the actual value that needs to be set @@ -338,31 +334,28 @@ func (ruleSet *URIRuleSet) Apply(ctx context.Context, input any, output any) err case reflect.Interface: outputElem.Set(reflect.ValueOf(valueStr)) default: - return errors.Collection(errors.Errorf( - errors.CodeInternal, ctx, "internal error", "cannot assign string to %T", output, - )) + return errors.Errorf(errors.CodeInternal, ctx, "internal error", "cannot assign string to %T", output) } return nil } // evaluateScheme evaluates the scheme portion of the URI and also returns a context with the scheme set. -func (ruleSet *URIRuleSet) evaluateScheme(ctx context.Context, value string) (context.Context, errors.ValidationErrorCollection) { +func (ruleSet *URIRuleSet) evaluateScheme(ctx context.Context, value string) (context.Context, errors.ValidationError) { newCtx := context.WithValue(ctx, URIContextKeyScheme, value) subContext := ruleSet.deepErrorContext(newCtx, "scheme") if value == "" { if !ruleSet.relative { - return newCtx, errors.Collection(errors.Errorf(errors.CodeRequired, subContext, "required", "scheme is required")) + return newCtx, errors.Errorf(errors.CodeRequired, subContext, "required", "scheme is required") } return newCtx, nil } - return newCtx, ruleSet.schemeRuleSet.Evaluate(subContext, value) } // evaluateUser evaluates the user portion of the userinfo in the URI and also returns a context with the user set. -func (ruleSet *URIRuleSet) evaluateUser(ctx context.Context, value string) (context.Context, errors.ValidationErrorCollection) { +func (ruleSet *URIRuleSet) evaluateUser(ctx context.Context, value string) (context.Context, errors.ValidationError) { newCtx := context.WithValue(ctx, URIContextKeyUser, value) subContext := ruleSet.deepErrorContext(newCtx, "user") @@ -370,7 +363,7 @@ func (ruleSet *URIRuleSet) evaluateUser(ctx context.Context, value string) (cont } // evaluatePassword evaluates the password portion of the userinfo in the URI and also returns a context with the password set. -func (ruleSet *URIRuleSet) evaluatePassword(ctx context.Context, value string) (context.Context, errors.ValidationErrorCollection) { +func (ruleSet *URIRuleSet) evaluatePassword(ctx context.Context, value string) (context.Context, errors.ValidationError) { newCtx := context.WithValue(ctx, URIContextKeyPassword, value) if value == "" && !ruleSet.passwordRuleSet.Required() { @@ -383,7 +376,7 @@ func (ruleSet *URIRuleSet) evaluatePassword(ctx context.Context, value string) ( } // evaluateAuthorityPart takes a context, a authority part name, and its value and returns any validation errors and a modified context. -func (ruleSet *URIRuleSet) evaluateUserinfoPart(ctx context.Context, name, value string) (context.Context, errors.ValidationErrorCollection) { +func (ruleSet *URIRuleSet) evaluateUserinfoPart(ctx context.Context, name, value string) (context.Context, errors.ValidationError) { switch name { case "user": return ruleSet.evaluateUser(ctx, value) @@ -394,7 +387,7 @@ func (ruleSet *URIRuleSet) evaluateUserinfoPart(ctx context.Context, name, value } // evaluateUserinfo evaluates the userinfo portion of the URI and also returns a context with the userinfo set. -func (ruleSet *URIRuleSet) evaluateUserinfo(ctx context.Context, value string) (context.Context, errors.ValidationErrorCollection) { +func (ruleSet *URIRuleSet) evaluateUserinfo(ctx context.Context, value string) (context.Context, errors.ValidationError) { const userinfoRegex = `^` + `(?P[^:]*)` + // User `([:]?)(?P.*)` + // Password @@ -403,53 +396,39 @@ func (ruleSet *URIRuleSet) evaluateUserinfo(ctx context.Context, value string) ( newCtx := context.WithValue(ctx, URIContextKeyUserinfo, value) if value == "" { - var verr errors.ValidationErrorCollection - + var errs errors.ValidationError if ruleSet.passwordRuleSet.Required() { subContext := ruleSet.deepErrorContext(newCtx, "password") - verr = append(verr, errors.Errorf(errors.CodeRequired, subContext, "required", "password is required")) + errs = errors.Join(errs, errors.Errorf(errors.CodeRequired, subContext, "required", "password is required")) } if ruleSet.userRuleSet.Required() { subContext := ruleSet.deepErrorContext(newCtx, "user") - verr = append(verr, errors.Errorf(errors.CodeRequired, subContext, "required", "user is required")) + errs = errors.Join(errs, errors.Errorf(errors.CodeRequired, subContext, "required", "user is required")) } - - if len(verr) > 0 { - return newCtx, verr - } - return newCtx, nil + return newCtx, errs } - allErrors := errors.Collection() + var errs errors.ValidationError r := regexp.MustCompile(userinfoRegex) match := r.FindStringSubmatch(value) - - var verr errors.ValidationErrorCollection - - // Regex always matches for i, name := range r.SubexpNames() { - // User is implicit but if there is no ':' we treat password as missing. - // The match right before password should be a colon or empty if name == "password" && match[i-1] == "" { if ruleSet.passwordRuleSet.Required() { subContext := ruleSet.deepErrorContext(newCtx, "password") - return newCtx, errors.Collection(errors.Errorf(errors.CodeRequired, subContext, "required", "password is required")) + return newCtx, errors.Join(errs, errors.Errorf(errors.CodeRequired, subContext, "required", "password is required")) } } - + var verr errors.ValidationError newCtx, verr = ruleSet.evaluateUserinfoPart(newCtx, name, match[i]) - allErrors = append(allErrors, verr...) - } - - if len(allErrors) > 0 { - return newCtx, allErrors + if verr != nil { + errs = errors.Join(errs, verr) + } } - - return newCtx, nil + return newCtx, errs } // evaluateHost evaluates the host portion of the URI and also returns a context with the host set. -func (ruleSet *URIRuleSet) evaluateHost(ctx context.Context, value string) (context.Context, errors.ValidationErrorCollection) { +func (ruleSet *URIRuleSet) evaluateHost(ctx context.Context, value string) (context.Context, errors.ValidationError) { newCtx := context.WithValue(ctx, URIContextKeyHost, value) subContext := ruleSet.deepErrorContext(newCtx, "host") @@ -457,7 +436,7 @@ func (ruleSet *URIRuleSet) evaluateHost(ctx context.Context, value string) (cont } // evaluatePort evaluates the port portion of the URI and also returns a context with the port set. -func (ruleSet *URIRuleSet) evaluatePort(ctx context.Context, value string) (context.Context, errors.ValidationErrorCollection) { +func (ruleSet *URIRuleSet) evaluatePort(ctx context.Context, value string) (context.Context, errors.ValidationError) { newCtx := context.WithValue(ctx, URIContextKeyPort, value) if value == "" && !ruleSet.portRuleSet.Required() { @@ -472,7 +451,7 @@ func (ruleSet *URIRuleSet) evaluatePort(ctx context.Context, value string) (cont } // evaluateAuthorityPart takes a context, a authority part name, and its value and returns any validation errors and a modified context. -func (ruleSet *URIRuleSet) evaluateAuthorityPart(ctx context.Context, name, value string) (context.Context, errors.ValidationErrorCollection) { +func (ruleSet *URIRuleSet) evaluateAuthorityPart(ctx context.Context, name, value string) (context.Context, errors.ValidationError) { switch name { case "userinfo": return ruleSet.evaluateUserinfo(ctx, value) @@ -485,31 +464,28 @@ func (ruleSet *URIRuleSet) evaluateAuthorityPart(ctx context.Context, name, valu } // evaluateAuthority evaluates the authority portion of the URI and also returns a context with the authority, host, port, and userinfo set. -func (ruleSet *URIRuleSet) evaluateAuthority(ctx context.Context, value string, missing bool) (context.Context, errors.ValidationErrorCollection) { - allErrors := errors.Collection() +func (ruleSet *URIRuleSet) evaluateAuthority(ctx context.Context, value string, missing bool) (context.Context, errors.ValidationError) { + var errs errors.ValidationError newCtx := context.WithValue(ctx, URIContextKeyAuthority, value) // Authority can be omitted from the URI. // If it is, that means that any required parts that are inside of the authority are missing. - // That means that we should trigger validation errors for any missing but required parts. - // Note: this is the ONLY way that host can be missing. All other parts are tested later as well. - // Previous value should be "//" if the authority is present if missing { if ruleSet.userRuleSet.Required() { subContext := ruleSet.deepErrorContext(newCtx, "user") - allErrors = append(allErrors, errors.Errorf(errors.CodeRequired, subContext, "required", "user is required")) + errs = errors.Join(errs, errors.Errorf(errors.CodeRequired, subContext, "required", "user is required")) } if ruleSet.passwordRuleSet.Required() { subContext := ruleSet.deepErrorContext(newCtx, "password") - allErrors = append(allErrors, errors.Errorf(errors.CodeRequired, subContext, "required", "password is required")) + errs = errors.Join(errs, errors.Errorf(errors.CodeRequired, subContext, "required", "password is required")) } if ruleSet.hostRuleSet.Required() { subContext := ruleSet.deepErrorContext(newCtx, "host") - allErrors = append(allErrors, errors.Errorf(errors.CodeRequired, subContext, "required", "host is required")) + errs = errors.Join(errs, errors.Errorf(errors.CodeRequired, subContext, "required", "host is required")) } if ruleSet.portRuleSet.Required() { subContext := ruleSet.deepErrorContext(newCtx, "port") - allErrors = append(allErrors, errors.Errorf(errors.CodeRequired, subContext, "required", "port is required")) + errs = errors.Join(errs, errors.Errorf(errors.CodeRequired, subContext, "required", "port is required")) } // These are usually set in evaluateURIPart but we are skipping that @@ -518,10 +494,9 @@ func (ruleSet *URIRuleSet) evaluateAuthority(ctx context.Context, value string, newCtx = context.WithValue(newCtx, URIContextKeyPassword, "") newCtx = context.WithValue(newCtx, URIContextKeyHost, "") newCtx = context.WithValue(newCtx, URIContextKeyPort, "") - return newCtx, allErrors + return newCtx, errs } - // Authority can be empty const authorityRegex = `^` + `(:?(?P[^@]*)@)?` + // Userinfo `(?P[^:]*)` + // Host @@ -530,32 +505,25 @@ func (ruleSet *URIRuleSet) evaluateAuthority(ctx context.Context, value string, r := regexp.MustCompile(authorityRegex) match := r.FindStringSubmatch(value) - - var verr errors.ValidationErrorCollection - - // Regex always matches since all parts are optional for i, name := range r.SubexpNames() { if name == "port" && match[i-1] == "" { if ruleSet.portRuleSet.Required() { subContext := ruleSet.deepErrorContext(newCtx, "port") - allErrors = append(allErrors, errors.Errorf(errors.CodeRequired, subContext, "required", "port is required")) + errs = errors.Join(errs, errors.Errorf(errors.CodeRequired, subContext, "required", "port is required")) continue } } - + var verr errors.ValidationError newCtx, verr = ruleSet.evaluateAuthorityPart(newCtx, name, match[i]) - allErrors = append(allErrors, verr...) - } - - if len(allErrors) > 0 { - return newCtx, allErrors + if verr != nil { + errs = errors.Join(errs, verr) + } } - - return newCtx, nil + return newCtx, errs } // evaluatePath evaluates the path portion of the URI and also returns a context with the path set. -func (ruleSet *URIRuleSet) evaluatePath(ctx context.Context, value string) (context.Context, errors.ValidationErrorCollection) { +func (ruleSet *URIRuleSet) evaluatePath(ctx context.Context, value string) (context.Context, errors.ValidationError) { newCtx := context.WithValue(ctx, URIContextKeyPath, value) subContext := ruleSet.deepErrorContext(newCtx, "path") @@ -563,23 +531,19 @@ func (ruleSet *URIRuleSet) evaluatePath(ctx context.Context, value string) (cont } // evaluateQuery evaluates the query portion of the URI and also returns a context with the query set. -func (ruleSet *URIRuleSet) evaluateQuery(ctx context.Context, value string, missing bool) (context.Context, errors.ValidationErrorCollection) { +func (ruleSet *URIRuleSet) evaluateQuery(ctx context.Context, value string, missing bool) (context.Context, errors.ValidationError) { newCtx := context.WithValue(ctx, URIContextKeyQuery, value) subContext := ruleSet.deepErrorContext(newCtx, "query") if missing { if ruleSet.queryRuleSet.Required() { - return newCtx, errors.Collection( - errors.Errorf(errors.CodeRequired, subContext, "required", "query is required"), - ) + return newCtx, errors.Errorf(errors.CodeRequired, subContext, "required", "query is required") } return newCtx, nil } values, parseErr := url.ParseQuery(value) if parseErr != nil { - return newCtx, errors.Collection( - errors.Errorf(errors.CodeEncoding, subContext, "invalid query", "query string could not be parsed: %v", parseErr), - ) + return newCtx, errors.Errorf(errors.CodeEncoding, subContext, "invalid query", "query string could not be parsed: %v", parseErr) } if err := ruleSet.queryRuleSet.Evaluate(subContext, values); err != nil { return newCtx, err @@ -588,24 +552,21 @@ func (ruleSet *URIRuleSet) evaluateQuery(ctx context.Context, value string, miss } // evaluateFragment evaluates the fragment portion of the URI and also returns a context with the fragment set. -func (ruleSet *URIRuleSet) evaluateFragment(ctx context.Context, value string, missing bool) (context.Context, errors.ValidationErrorCollection) { +func (ruleSet *URIRuleSet) evaluateFragment(ctx context.Context, value string, missing bool) (context.Context, errors.ValidationError) { newCtx := context.WithValue(ctx, URIContextKeyFragment, value) subContext := ruleSet.deepErrorContext(newCtx, "fragment") if missing { if ruleSet.fragmentRuleSet.Required() { - return newCtx, errors.Collection( - errors.Errorf(errors.CodeRequired, subContext, "required", "fragment is required"), - ) + return newCtx, errors.Errorf(errors.CodeRequired, subContext, "required", "fragment is required") } return newCtx, nil } - return newCtx, ruleSet.fragmentRuleSet.Evaluate(subContext, value) } // evaluateURIPart takes a context, a URI part name, and its value and returns any validation errors and a modified context. -func (ruleSet *URIRuleSet) evaluateURIPart(ctx context.Context, name, value, previousValue string) (context.Context, errors.ValidationErrorCollection) { +func (ruleSet *URIRuleSet) evaluateURIPart(ctx context.Context, name, value, previousValue string) (context.Context, errors.ValidationError) { switch name { case "scheme": return ruleSet.evaluateScheme(ctx, value) @@ -622,8 +583,8 @@ func (ruleSet *URIRuleSet) evaluateURIPart(ctx context.Context, name, value, pre } // Evaluate performs a validation of a RuleSet against a string and returns an object value of the -// same type or a ValidationErrorCollection. -func (ruleSet *URIRuleSet) Evaluate(ctx context.Context, value string) errors.ValidationErrorCollection { +// same type or a ValidationError. +func (ruleSet *URIRuleSet) Evaluate(ctx context.Context, value string) errors.ValidationError { const URIRegex = `^` + `(?:(?P[^:/?#]+):)?` + // Scheme `(?:(//)(?P[^/?#]*))?` + // Authority @@ -634,37 +595,27 @@ func (ruleSet *URIRuleSet) Evaluate(ctx context.Context, value string) errors.Va r := regexp.MustCompile(URIRegex) match := r.FindStringSubmatch(value) - - allErrors := errors.Collection() - + var errs errors.ValidationError currentRuleSet := ruleSet ctx = rulecontext.WithRuleSet(ctx, ruleSet) - - var verr errors.ValidationErrorCollection - - // Regex always matches prevMatch := "" for i, name := range r.SubexpNames() { + var verr errors.ValidationError ctx, verr = ruleSet.evaluateURIPart(ctx, name, match[i], prevMatch) - allErrors = append(allErrors, verr...) + if verr != nil { + errs = errors.Join(errs, verr) + } prevMatch = match[i] } - for currentRuleSet != nil { if currentRuleSet.rule != nil { - if errs := currentRuleSet.rule.Evaluate(ctx, value); errs != nil { - allErrors = append(allErrors, errs...) + if e := currentRuleSet.rule.Evaluate(ctx, value); e != nil { + errs = errors.Join(errs, e) } } - currentRuleSet = currentRuleSet.parent } - - if len(allErrors) > 0 { - return allErrors - } - - return nil + return errs } // noConflict returns the new array rule set with all conflicting rules removed. diff --git a/pkg/rules/net/uri_test.go b/pkg/rules/net/uri_test.go index ba06b85..3c2e774 100644 --- a/pkg/rules/net/uri_test.go +++ b/pkg/rules/net/uri_test.go @@ -27,9 +27,9 @@ func uriPartRequiredMissingHelper(t testing.TB, name, value string, withRequired if err == nil { t.Errorf("Expected shallow error to not be nil on %s", value) - } else if code := err.First().Code(); code != errors.CodeRequired { + } else if code := err.Code(); code != errors.CodeRequired { t.Errorf("Expected shallow error code of %s, got %s (%s)", errors.CodeRequired, code, err) - } else if path := err.First().Path(); path != "/uri" { + } else if path := err.Path(); path != "/uri" { t.Errorf("Expected shallow error path of %s, got %s (on %s)", "/uri/"+name, path, value) } @@ -38,9 +38,9 @@ func uriPartRequiredMissingHelper(t testing.TB, name, value string, withRequired if err == nil { t.Errorf("Expected deep error to not be nil on %s", value) - } else if code := err.First().Code(); code != errors.CodeRequired { + } else if code := err.Code(); code != errors.CodeRequired { t.Errorf("Expected deep error code of %s, got %s (%s on %s)", errors.CodeRequired, code, err, value) - } else if path := err.First().Path(); path != "/uri/"+name { + } else if path := err.Path(); path != "/uri/"+name { t.Errorf("Expected deep error path of %s, got %s (on %s)", "/uri/"+name, path, value) } } @@ -181,7 +181,7 @@ func TestURIRuleSet_Apply_SchemeCharacterSet(t *testing.T) { func TestURIRuleSet_CustomContext(t *testing.T) { var ctxRef context.Context - fn := func(ctx context.Context, value string) errors.ValidationErrorCollection { + fn := func(ctx context.Context, value string) errors.ValidationError { ctxRef = ctx return nil } @@ -299,12 +299,14 @@ func TestURIRuleSet_Apply_DeepErrors(t *testing.T) { } for path, value := range tests { - errs := ruleSet.Apply(ctx, value, &output) + errs := errors.Unwrap(ruleSet.Apply(ctx, value, &output)) if len(errs) != 1 { t.Errorf("Expected 1 error for %s, got: %d", path, len(errs)) - } else if errPath := errs.First().Path(); errPath != "/url" { - t.Errorf("Expected path for %s to be `/url`, got: %s", path, errPath) + } else if ve, ok := errs[0].(errors.ValidationError); ok { + if errPath := ve.Path(); errPath != "/url" { + t.Errorf("Expected path for %s to be `/url`, got: %s", path, errPath) + } } } @@ -320,12 +322,14 @@ func TestURIRuleSet_Apply_DeepErrors(t *testing.T) { } for path, value := range tests { - errs := ruleSet.Apply(ctx, value, &output) + errs := errors.Unwrap(ruleSet.Apply(ctx, value, &output)) if len(errs) != 1 { // We would have already printed this error - } else if errPath := errs.First().Path(); errPath != "/url/"+path { - t.Errorf("Expected path for %s to be `/url/%s`, got: %s", path, path, errPath) + } else if ve, ok := errs[0].(errors.ValidationError); ok { + if errPath := ve.Path(); errPath != "/url/"+path { + t.Errorf("Expected path for %s to be `/url/%s`, got: %s", path, path, errPath) + } } } } @@ -558,8 +562,8 @@ func TestURIRuleSet_WithRuleFunc(t *testing.T) { if err == nil { t.Error("Expected errors to not be nil") - } else if len(err) != 2 { - t.Errorf("Expected 2 errors, got: %d", len(err)) + } else if len(errors.Unwrap(err)) != 2 { + t.Errorf("Expected 2 errors, got: %d", len(errors.Unwrap(err))) } } diff --git a/pkg/rules/number_rule_max.go b/pkg/rules/number_rule_max.go index 343ddc2..8f38ab4 100644 --- a/pkg/rules/number_rule_max.go +++ b/pkg/rules/number_rule_max.go @@ -14,11 +14,9 @@ type maxRule[T integer | floating] struct { } // Evaluate takes a context and integer value and returns an error if it is not equal or higher than the specified value. -func (rule *maxRule[T]) Evaluate(ctx context.Context, value T) errors.ValidationErrorCollection { +func (rule *maxRule[T]) Evaluate(ctx context.Context, value T) errors.ValidationError { if value > rule.max { - return errors.Collection( - errors.Error(errors.CodeMax, ctx, rule.max), - ) + return errors.Error(errors.CodeMax, ctx, rule.max) } return nil diff --git a/pkg/rules/number_rule_maxexclusive.go b/pkg/rules/number_rule_maxexclusive.go index 6115733..0907760 100644 --- a/pkg/rules/number_rule_maxexclusive.go +++ b/pkg/rules/number_rule_maxexclusive.go @@ -14,11 +14,9 @@ type maxExclusiveRule[T integer | floating] struct { } // Evaluate takes a context and value and returns an error if it is not less than the specified value (exclusive). -func (rule *maxExclusiveRule[T]) Evaluate(ctx context.Context, value T) errors.ValidationErrorCollection { +func (rule *maxExclusiveRule[T]) Evaluate(ctx context.Context, value T) errors.ValidationError { if value >= rule.max { - return errors.Collection( - errors.Error(errors.CodeMaxExclusive, ctx, rule.max), - ) + return errors.Error(errors.CodeMaxExclusive, ctx, rule.max) } return nil diff --git a/pkg/rules/number_rule_min.go b/pkg/rules/number_rule_min.go index e25b06c..29192eb 100644 --- a/pkg/rules/number_rule_min.go +++ b/pkg/rules/number_rule_min.go @@ -14,11 +14,9 @@ type minRule[T integer | floating] struct { } // Evaluate takes a context and integer value and returns an error if it is not equal or greater than the specified value. -func (rule *minRule[T]) Evaluate(ctx context.Context, value T) errors.ValidationErrorCollection { +func (rule *minRule[T]) Evaluate(ctx context.Context, value T) errors.ValidationError { if value < rule.min { - return errors.Collection( - errors.Error(errors.CodeMin, ctx, rule.min), - ) + return errors.Error(errors.CodeMin, ctx, rule.min) } return nil diff --git a/pkg/rules/number_rule_minexclusive.go b/pkg/rules/number_rule_minexclusive.go index cb9d5fd..572d025 100644 --- a/pkg/rules/number_rule_minexclusive.go +++ b/pkg/rules/number_rule_minexclusive.go @@ -14,11 +14,9 @@ type minExclusiveRule[T integer | floating] struct { } // Evaluate takes a context and value and returns an error if it is not greater than the specified value (exclusive). -func (rule *minExclusiveRule[T]) Evaluate(ctx context.Context, value T) errors.ValidationErrorCollection { +func (rule *minExclusiveRule[T]) Evaluate(ctx context.Context, value T) errors.ValidationError { if value <= rule.min { - return errors.Collection( - errors.Error(errors.CodeMinExclusive, ctx, rule.min), - ) + return errors.Error(errors.CodeMinExclusive, ctx, rule.min) } return nil diff --git a/pkg/rules/number_rule_values.go b/pkg/rules/number_rule_values.go index 5afe9c1..e49ac26 100644 --- a/pkg/rules/number_rule_values.go +++ b/pkg/rules/number_rule_values.go @@ -37,19 +37,15 @@ func (rule *valuesRule[T]) exists(value T) bool { // Evaluate takes a context and string value and returns an error depending on whether the value is in a list // of allowed or denied values. -func (rule *valuesRule[T]) Evaluate(ctx context.Context, value T) errors.ValidationErrorCollection { +func (rule *valuesRule[T]) Evaluate(ctx context.Context, value T) errors.ValidationError { exists := rule.exists(value) if rule.allow { if !exists { - return errors.Collection( - errors.Error(errors.CodeNotAllowed, ctx), - ) + return errors.Error(errors.CodeNotAllowed, ctx) } } else if exists { - return errors.Collection( - errors.Error(errors.CodeForbidden, ctx), - ) + return errors.Error(errors.CodeForbidden, ctx) } return nil diff --git a/pkg/rules/object.go b/pkg/rules/object.go index b20cc1b..b62cf8a 100644 --- a/pkg/rules/object.go +++ b/pkg/rules/object.go @@ -427,27 +427,29 @@ func contextErrorToValidation(ctx context.Context) errors.ValidationError { } // wait blocks until either the context is cancelled or the wait group is done (all keys have been validated). -func wait(ctx context.Context, wg *sync.WaitGroup, errorsCh chan errors.ValidationErrorCollection, listenForCancelled bool) errors.ValidationErrorCollection { +func wait(ctx context.Context, wg *sync.WaitGroup, errorsCh chan errors.ValidationError, listenForCancelled bool) errors.ValidationError { done := make(chan struct{}) - go func() { wg.Wait() close(done) }() - - allErrors := errors.Collection() - + var errs errors.ValidationError for { select { case err := <-errorsCh: - allErrors = append(allErrors, err...) + if err != nil { + errs = errors.Join(errs, err) + } case <-ctx.Done(): if listenForCancelled { wg.Wait() - return append(allErrors, contextErrorToValidation(ctx)) + if ev := contextErrorToValidation(ctx); ev != nil { + errs = errors.Join(errs, ev) + } + return errs } case <-done: - return allErrors + return errs } } } @@ -464,7 +466,7 @@ func done(ctx context.Context) bool { // evaluateKeyRule evaluates a single key rule. // Note that this function is meant to be called on the rule set that contains the rule. -func (ruleSet *ObjectRuleSet[T, TK, TV]) evaluateKeyRule(ctx context.Context, out *T, wg *sync.WaitGroup, outValueMutex *sync.Mutex, errorsCh chan errors.ValidationErrorCollection, key TK, inFieldValue reflect.Value, s setter[TK], counters *counterSet[TK], dynamicBuckets []*ObjectRuleSet[T, TK, TV]) { +func (ruleSet *ObjectRuleSet[T, TK, TV]) evaluateKeyRule(ctx context.Context, out *T, wg *sync.WaitGroup, outValueMutex *sync.Mutex, errorsCh chan errors.ValidationError, key TK, inFieldValue reflect.Value, s setter[TK], counters *counterSet[TK], dynamicBuckets []*ObjectRuleSet[T, TK, TV]) { defer wg.Done() counters.Lock(key) defer counters.Unlock(key) @@ -492,9 +494,7 @@ func (ruleSet *ObjectRuleSet[T, TK, TV]) evaluateKeyRule(ctx context.Context, ou if inFieldValue.Kind() == reflect.Invalid { if ruleSet.rule.Required() { - errorsCh <- errors.Collection( - errors.Error(errors.CodeRequired, ctx), - ) + errorsCh <- errors.Error(errors.CodeRequired, ctx) } return } @@ -542,8 +542,8 @@ func (v *ObjectRuleSet[T, TK, TV]) keyValue(key TK, currentRuleSet *ObjectRuleSe } // evaluateKeyRules evaluates the rules for each key and called evaluateKeyRule. -func (v *ObjectRuleSet[T, TK, TV]) evaluateKeyRules(ctx context.Context, out *T, inValue reflect.Value, s setter[TK], fromMap, fromSame bool) errors.ValidationErrorCollection { - allErrors := errors.Collection() +func (v *ObjectRuleSet[T, TK, TV]) evaluateKeyRules(ctx context.Context, out *T, inValue reflect.Value, s setter[TK], fromMap, fromSame bool) errors.ValidationError { + var errs errors.ValidationError var emptyKey TK // Pre caching a list of dynamic buckets lets us avoid extra loops. @@ -587,7 +587,7 @@ func (v *ObjectRuleSet[T, TK, TV]) evaluateKeyRules(ctx context.Context, out *T, } // Handle concurrency for the rule evaluation - errorsCh := make(chan errors.ValidationErrorCollection) + errorsCh := make(chan errors.ValidationError) defer close(errorsCh) var outValueMutex sync.Mutex @@ -624,17 +624,16 @@ func (v *ObjectRuleSet[T, TK, TV]) evaluateKeyRules(ctx context.Context, out *T, } } - // Unknown fields are not concurrent for now so we need to wait for all rule evaluations to finish ruleErrors := wait(ctx, &wg, errorsCh, true) + if ruleErrors != nil { + errs = errors.Join(errs, ruleErrors) + } - // Throw all applicable unknown keys into dynamic buckets. - // Keys in dynamic buckets should not trigger an unknown key error. if len(dynamicBuckets) > 0 { unk := knownKeys.Unknown(inValue) for _, key := range unk { for _, bucketRuleSet := range dynamicBuckets { inFieldValue := v.keyValue(key, bucketRuleSet, inValue, fromMap, fromSame) - if bucketRuleSet.key.Evaluate(ctx, key) == nil && (bucketRuleSet.condition == nil || bucketRuleSet.condition.Evaluate(ctx, *out) == nil) { knownKeys.Add(key) s.SetBucket(bucketRuleSet.bucket, key, inFieldValue.Interface()) @@ -643,26 +642,24 @@ func (v *ObjectRuleSet[T, TK, TV]) evaluateKeyRules(ctx context.Context, out *T, } } - // Check for unknown values if !v.allowUnknown { - // If allowUnknown is not set we want to error for each unknown value - knownKeyErrors := knownKeys.Check(ctx, inValue) - allErrors = append(allErrors, knownKeyErrors...) + if knownKeyErrors := knownKeys.Check(ctx, inValue); knownKeyErrors != nil { + errs = errors.Join(errs, knownKeyErrors) + } } else if fromMap && s.Map() { - // If allowUnknown is set and the output is a map we want to assign each key to the map output. for _, key := range knownKeys.Unknown(inValue) { s.Set(key, inValue.MapIndex(reflect.ValueOf(key)).Interface()) } } - return append(allErrors, ruleErrors...) + return errs } // evaluateObjectRules evaluates the object -func (v *ObjectRuleSet[T, TK, TV]) evaluateObjectRules(ctx context.Context, out *T) errors.ValidationErrorCollection { +func (v *ObjectRuleSet[T, TK, TV]) evaluateObjectRules(ctx context.Context, out *T) errors.ValidationError { var wg sync.WaitGroup var outValueMutex sync.Mutex - errorsCh := make(chan errors.ValidationErrorCollection) + errorsCh := make(chan errors.ValidationError) defer close(errorsCh) for currentRuleSet := v; currentRuleSet != nil; currentRuleSet = currentRuleSet.parent { @@ -670,25 +667,20 @@ func (v *ObjectRuleSet[T, TK, TV]) evaluateObjectRules(ctx context.Context, out if done(ctx) { break } - wg.Add(1) go func(objRule Rule[T]) { outValueMutex.Lock() defer outValueMutex.Unlock() defer wg.Done() - if done(ctx) { return } - if err := objRule.Evaluate(ctx, *out); err != nil { errorsCh <- err } - }(currentRuleSet.objRule) } } - return wait(ctx, &wg, errorsCh, !done(ctx)) } @@ -707,8 +699,8 @@ func (ruleSet *ObjectRuleSet[T, TK, TV]) newSetter(outValue reflect.Value) sette } // Apply performs validation of a RuleSet against a value and assigns the result to the output parameter. -// Apply returns a ValidationErrorCollection if any validation errors occur. -func (v *ObjectRuleSet[T, TK, TV]) Apply(ctx context.Context, value any, output any) errors.ValidationErrorCollection { +// Apply returns a ValidationError if any validation errors occur. +func (v *ObjectRuleSet[T, TK, TV]) Apply(ctx context.Context, value any, output any) errors.ValidationError { // Add error config to context for error customization ctx = errors.WithErrorConfig(ctx, v.errorConfig) @@ -720,9 +712,7 @@ func (v *ObjectRuleSet[T, TK, TV]) Apply(ctx context.Context, value any, output // Ensure output is a non-nil pointer rv := reflect.ValueOf(output) if rv.Kind() != reflect.Ptr || rv.IsNil() { - return errors.Collection(errors.Errorf( - errors.CodeInternal, ctx, "internal error", "output must be a non-nil pointer", - )) + return errors.Errorf(errors.CodeInternal, ctx, "internal error", "output must be a non-nil pointer") } // If this is true we need to assign the output at the end of the Apply since we can't assign it directly initially. @@ -780,7 +770,7 @@ func (v *ObjectRuleSet[T, TK, TV]) Apply(ctx context.Context, value any, output // We're pointing to a nil interface{} // We can't set up the pointer now so we'll need to deal with it later if !reflect.ValueOf(out).Type().AssignableTo(elem.Type()) { - return errors.Collection(errors.Errorf(errors.CodeInternal, ctx, "internal error", "cannot assign %T to %T", out, output)) + return errors.Errorf(errors.CodeInternal, ctx, "internal error", "cannot assign %T to %T", out, output) } assignLater = true @@ -800,7 +790,7 @@ func (v *ObjectRuleSet[T, TK, TV]) Apply(ctx context.Context, value any, output } } else { - return errors.Collection(errors.Errorf(errors.CodeInternal, ctx, "internal error", "cannot assign %T to %T", out, output)) + return errors.Errorf(errors.CodeInternal, ctx, "internal error", "cannot assign %T to %T", out, output) } var outValue reflect.Value @@ -834,9 +824,7 @@ func (v *ObjectRuleSet[T, TK, TV]) Apply(ctx context.Context, value any, output } if !coerced && attempted { - return errors.Collection( - errors.Error(errors.CodeType, ctx, "object, map, or JSON string", inKind.String()), - ) + return errors.Error(errors.CodeType, ctx, "object, map, or JSON string", inKind.String()) } if attempted { @@ -849,34 +837,21 @@ func (v *ObjectRuleSet[T, TK, TV]) Apply(ctx context.Context, value any, output fromSame := !fromMap && inValue.Type() == v.outputType if !fromMap && inKind != reflect.Struct { - return errors.Collection( - errors.Error(errors.CodeType, ctx, "object or map", inKind.String()), - ) + return errors.Error(errors.CodeType, ctx, "object or map", inKind.String()) } - allErrors := errors.Collection() - - // Evaluate key rules keyErrs := v.evaluateKeyRules(ctx, out, inValue, s, fromMap, fromSame) - allErrors = append(allErrors, keyErrs...) - - // Evaluate object rules valErrs := v.evaluateObjectRules(ctx, out) - allErrors = append(allErrors, valErrs...) - - if len(allErrors) > 0 { - return allErrors - } + errs := errors.Join(keyErrs, valErrs) if assignLater { elem.Set(reflect.ValueOf(out).Elem()) } - - return nil + return errs } -// Evaluate performs validation of a RuleSet against a value of the object type and returns a ValidationErrorCollection. -func (ruleSet *ObjectRuleSet[T, TK, TV]) Evaluate(ctx context.Context, value T) errors.ValidationErrorCollection { +// Evaluate performs validation of a RuleSet against a value of the object type and returns a ValidationError. +func (ruleSet *ObjectRuleSet[T, TK, TV]) Evaluate(ctx context.Context, value T) errors.ValidationError { // Prepare a variable to hold the output after applying the rule set var output T diff --git a/pkg/rules/object_interface_test.go b/pkg/rules/object_interface_test.go index 54a7536..8cef329 100644 --- a/pkg/rules/object_interface_test.go +++ b/pkg/rules/object_interface_test.go @@ -10,7 +10,7 @@ import ( func InitInterfaceRuleSet() rules.RuleSet[MyTestInterface] { return rules.Interface[MyTestInterface](). - WithCast(func(ctx context.Context, v any) (MyTestInterface, errors.ValidationErrorCollection) { + WithCast(func(ctx context.Context, v any) (MyTestInterface, errors.ValidationError) { if v == nil { return nil, nil } diff --git a/pkg/rules/object_test.go b/pkg/rules/object_test.go index e231ac2..1fe4cb6 100644 --- a/pkg/rules/object_test.go +++ b/pkg/rules/object_test.go @@ -107,7 +107,7 @@ func TestObjectOutput_Apply(t *testing.T) { // Non pointer err = ruleSet.Apply(ctx, input, out1) - if err == nil || err.First().Code() != errors.CodeInternal { + if err == nil || err.Code() != errors.CodeInternal { t.Errorf("Expected error to not be internal") } @@ -129,7 +129,7 @@ func TestObjectOutput_Apply(t *testing.T) { // Pointer to incorrect type var out4 int err = ruleSet.Apply(ctx, input, out4) - if err == nil || err.First().Code() != errors.CodeInternal { + if err == nil || err.Code() != errors.CodeInternal { t.Errorf("Expected error to not be internal") } @@ -184,7 +184,7 @@ func TestObjectOutput_Apply(t *testing.T) { t.Errorf("Expected error to not be nil") } else if out7 != nil { t.Error("Expected out7 to be nil") - } else if c := err.First().Code(); c != errors.CodeInternal { + } else if c := err.Code(); c != errors.CodeInternal { t.Errorf("Expected error to be %s (errors.CodeInternal), got: %s", errors.CodeInternal, c) } } @@ -222,7 +222,7 @@ func TestObjectOutputPointer_Apply(t *testing.T) { // Non pointer err = ruleSet.Apply(ctx, input, out1) - if err == nil || err.First().Code() != errors.CodeInternal { + if err == nil || err.Code() != errors.CodeInternal { t.Errorf("Expected error to not be internal") } @@ -265,7 +265,7 @@ func TestObjectOutputPointer_Apply(t *testing.T) { // Pointer to incorrect type var out4 int err = ruleSet.Apply(ctx, input, out4) - if err == nil || err.First().Code() != errors.CodeInternal { + if err == nil || err.Code() != errors.CodeInternal { t.Errorf("Expected error to not be internal") } } @@ -601,7 +601,7 @@ func TestMissingRequiredField(t *testing.T) { WithKey("B", rules.Int().WithRequired()). Apply(context.TODO(), map[string]any{"A": 123}, &out) - if len(err) == 0 { + if len(errors.Unwrap(err)) == 0 { t.Errorf("Expected errors to not be empty") } } @@ -654,8 +654,8 @@ func TestReturnsAllErrors(t *testing.T) { if err == nil { t.Errorf("Expected errors to not be nil") - } else if len(err) != 2 { - t.Errorf("Expected 2 errors got %d: %s", len(err), err.Error()) + } else if len(errors.Unwrap(err)) != 2 { + t.Errorf("Expected 2 errors got %d: %s", len(errors.Unwrap(err)), err.Error()) } } @@ -676,27 +676,27 @@ func TestObjectReturnsCorrectPaths(t *testing.T) { if err == nil { t.Errorf("Expected errors to not be nil") - } else if len(err) != 2 { - t.Errorf("Expected 2 errors got %d: %s", len(err), err.Error()) + } else if len(errors.Unwrap(err)) != 2 { + t.Errorf("Expected 2 errors got %d: %s", len(errors.Unwrap(err)), err.Error()) return } - errA := err.For("/myobj/A") + errA := errors.For(err, "/myobj/A") if errA == nil { t.Errorf("Expected error for /myobj/A to not be nil") - } else if len(errA) != 1 { - t.Errorf("Expected exactly 1 error for /myobj/A got %d: %s", len(err), err) - } else if errA.First().Path() != "/myobj/A" { - t.Errorf("Expected error path to be `%s` got `%s`", "/myobj/A", errA.First().Path()) + } else if len(errors.Unwrap(errA)) != 1 { + t.Errorf("Expected exactly 1 error for /myobj/A got %d: %s", len(errors.Unwrap(err)), err) + } else if errA.Path() != "/myobj/A" { + t.Errorf("Expected error path to be `%s` got `%s`", "/myobj/A", errA.Path()) } - errC := err.For("/myobj/C") + errC := errors.For(err, "/myobj/C") if errC == nil { t.Errorf("Expected error for /myobj/C to not be nil") - } else if len(errC) != 1 { - t.Errorf("Expected exactly 1 error for /myobj/C got %d: %s", len(err), err) - } else if errC.First().Path() != "/myobj/C" { - t.Errorf("Expected error path to be `%s` got `%s`", "/myobj/C", errC.First().Path()) + } else if len(errors.Unwrap(errC)) != 1 { + t.Errorf("Expected exactly 1 error for /myobj/C got %d: %s", len(errors.Unwrap(err)), err) + } else if errC.Path() != "/myobj/C" { + t.Errorf("Expected error path to be `%s` got `%s`", "/myobj/C", errC.Path()) } } @@ -736,9 +736,9 @@ func TestObjectCustom(t *testing.T) { if err == nil { t.Error("Expected errors to not be nil") - } else if len(err) != 5 { + } else if len(errors.Unwrap(err)) != 5 { // The two custom errors + 3 unexpected keys - t.Errorf("Expected 5 errors, got: %d", len(err)) + t.Errorf("Expected 5 errors, got: %d", len(errors.Unwrap(err))) } if mock.EvaluateCallCount() != 2 { @@ -908,7 +908,7 @@ func TestTimeoutInObjectRule(t *testing.T) { ruleSet := rules.Struct[*testStruct](). WithKey("X", rules.Int().WithMin(2).Any()). - WithRuleFunc(func(_ context.Context, x *testStruct) errors.ValidationErrorCollection { + WithRuleFunc(func(_ context.Context, x *testStruct) errors.ValidationError { // Simulate a delay that exceeds the timeout time.Sleep(1 * time.Second) return nil @@ -922,10 +922,18 @@ func TestTimeoutInObjectRule(t *testing.T) { if errs == nil { t.Error("Expected errors to not be nil") - } else if len(errs) != 2 { - t.Errorf("Expected 2 errors, got %d", len(errs)) - } else if c := errs.For("").First().Code(); c != errors.CodeTimeout { - t.Errorf("Expected error to be %s, got %s (%s)", errors.CodeTimeout, c, errs.For("").First()) + } else if all := errors.Unwrap(errs); len(all) != 2 { + t.Errorf("Expected 2 errors, got %d", len(all)) + } else { + codes := map[errors.ErrorCode]bool{} + for _, e := range all { + if ve, ok := e.(errors.ValidationError); ok { + codes[ve.Code()] = true + } + } + if !codes[errors.CodeTimeout] || !codes[errors.CodeMin] { + t.Errorf("Expected one CodeTimeout and one CodeMin, got: %s", errs) + } } } @@ -940,7 +948,7 @@ func TestTimeoutInKeyRule(t *testing.T) { ruleSet := rules.Struct[*testStruct](). WithKey("X", rules.Int(). - WithRuleFunc(func(_ context.Context, x int) errors.ValidationErrorCollection { + WithRuleFunc(func(_ context.Context, x int) errors.ValidationError { // Simulate a delay that exceeds the timeout time.Sleep(1 * time.Second) return nil @@ -954,10 +962,10 @@ func TestTimeoutInKeyRule(t *testing.T) { if errs == nil { t.Error("Expected errors to not be nil") - } else if len(errs) != 1 { - t.Errorf("Expected 1 error, got %d: %s", len(errs), errs) - } else if c := errs.For("").First().Code(); c != errors.CodeTimeout { - t.Errorf("Expected error to be %s, got %s (%s)", errors.CodeTimeout, c, errs.For("").First()) + } else if len(errors.Unwrap(errs)) != 1 { + t.Errorf("Expected 1 error, got %d: %s", len(errors.Unwrap(errs)), errs) + } else if c := errs.Code(); c != errors.CodeTimeout { + t.Errorf("Expected error to be %s, got %s (%s)", errors.CodeTimeout, c, errs) } } @@ -969,14 +977,14 @@ func TestCancelled(t *testing.T) { var intCallCount int32 = 0 var structCallCount int32 = 0 - intRule := func(_ context.Context, x int) errors.ValidationErrorCollection { + intRule := func(_ context.Context, x int) errors.ValidationError { atomic.AddInt32(&intCallCount, 1) cancel() time.Sleep(1 * time.Second) // Simulate a delay that allows cancellation return nil } - structRule := func(_ context.Context, x *testStruct) errors.ValidationErrorCollection { + structRule := func(_ context.Context, x *testStruct) errors.ValidationError { atomic.AddInt32(&structCallCount, 1) time.Sleep(1 * time.Second) // Simulate a delay that allows cancellation return nil @@ -996,10 +1004,10 @@ func TestCancelled(t *testing.T) { if errs == nil { t.Error("Expected errors to not be nil") - } else if len(errs) != 1 { - t.Errorf("Expected 1 error, got %d: %s", len(errs), errs) - } else if c := errs.First().Code(); c != errors.CodeCancelled { - t.Errorf("Expected error to be %s, got %s (%s)", errors.CodeCancelled, c, errs.First()) + } else if len(errors.Unwrap(errs)) != 1 { + t.Errorf("Expected 1 error, got %d: %s", len(errors.Unwrap(errs)), errs) + } else if c := errs.Code(); c != errors.CodeCancelled { + t.Errorf("Expected error to be %s, got %s (%s)", errors.CodeCancelled, c, errs) } finalCallCount := atomic.LoadInt32(&intCallCount) @@ -1020,7 +1028,7 @@ func TestCancelledObjectRules(t *testing.T) { var structCallCount int32 = 0 - structRule := func(_ context.Context, x *testStruct) errors.ValidationErrorCollection { + structRule := func(_ context.Context, x *testStruct) errors.ValidationError { atomic.AddInt32(&structCallCount, 1) cancel() time.Sleep(1 * time.Second) // Simulate a delay that allows cancellation @@ -1039,10 +1047,10 @@ func TestCancelledObjectRules(t *testing.T) { if errs == nil { t.Error("Expected errors to not be nil") - } else if len(errs) != 1 { - t.Errorf("Expected 1 error, got %d: %s", len(errs), errs) - } else if c := errs.First().Code(); c != errors.CodeCancelled { - t.Errorf("Expected error to be %s, got %s (%s)", errors.CodeCancelled, c, errs.First()) + } else if len(errors.Unwrap(errs)) != 1 { + t.Errorf("Expected 1 error, got %d: %s", len(errors.Unwrap(errs)), errs) + } else if c := errs.Code(); c != errors.CodeCancelled { + t.Errorf("Expected error to be %s, got %s (%s)", errors.CodeCancelled, c, errs) } finalCallCount := atomic.LoadInt32(&structCallCount) @@ -1060,14 +1068,14 @@ func TestConditionalKey(t *testing.T) { var condValue int32 = 0 // If the condition is evaluated before this rule finishes then the value will be incorrect - intRule := func(_ context.Context, x int) errors.ValidationErrorCollection { + intRule := func(_ context.Context, x int) errors.ValidationError { atomic.StoreInt32(&intState, 1) time.Sleep(100 * time.Millisecond) atomic.StoreInt32(&intState, 2) return nil } - condValueRule := func(_ context.Context, y int) errors.ValidationErrorCollection { + condValueRule := func(_ context.Context, y int) errors.ValidationError { condValue = atomic.LoadInt32(&intState) return nil } @@ -1431,16 +1439,16 @@ func TestUnexpectedKeyPath(t *testing.T) { if err == nil { t.Errorf("Expected errors to not be nil") return - } else if len(err) != 1 { - t.Errorf("Expected 1 error, got %d: %s", len(err), err.Error()) + } else if len(errors.Unwrap(err)) != 1 { + t.Errorf("Expected 1 error, got %d: %s", len(errors.Unwrap(err)), err.Error()) return } - if err.First().Path() != "/myobj/x" { - t.Errorf("Expected error path to be `%s` got `%s` (%s)", "/myobj/x", err.First().Path(), err) + if err.Path() != "/myobj/x" { + t.Errorf("Expected error path to be `%s` got `%s` (%s)", "/myobj/x", err.Path(), err) } - errA := err.For("/myobj/x") + errA := errors.For(err, "/myobj/x") if errA == nil { t.Errorf("Expected error for /myobj/x to not be nil") } @@ -1756,7 +1764,7 @@ func TestWithDynamicBucketAndDynamicKeyInterfaceToStruct(t *testing.T) { filterKeyRule := rules.String().WithRegexp(regexp.MustCompile(`^filter\[[^\]]+\]$`), "") fieldsRuleSet := rules.Interface[valueListForBucketTest]().WithCast( - func(ctx context.Context, value any) (valueListForBucketTest, errors.ValidationErrorCollection) { + func(ctx context.Context, value any) (valueListForBucketTest, errors.ValidationError) { var strs []string if errs := stringQueryValueRuleSet.Apply(ctx, value, &strs); errs != nil { return nil, errs @@ -1973,7 +1981,7 @@ func TestStaticKeyWithBucket(t *testing.T) { func TestDynamicKeyAsConditionalDependency(t *testing.T) { var callCount int32 = 0 - valueRule := rules.Any().WithRuleFunc(func(ctx context.Context, _ any) errors.ValidationErrorCollection { + valueRule := rules.Any().WithRuleFunc(func(ctx context.Context, _ any) errors.ValidationError { if rulecontext.Path(ctx).String() == "__abc" { time.Sleep(200 * time.Millisecond) atomic.AddInt32(&callCount, 1) @@ -1981,9 +1989,9 @@ func TestDynamicKeyAsConditionalDependency(t *testing.T) { return nil }) - finalValueRule := rules.Any().WithRuleFunc(func(ctx context.Context, _ any) errors.ValidationErrorCollection { + finalValueRule := rules.Any().WithRuleFunc(func(ctx context.Context, _ any) errors.ValidationError { if count := atomic.LoadInt32(&callCount); count != 2 { - return errors.Collection(errors.Errorf(errors.CodeCancelled, ctx, "cancelled", "Expected count of %d, got %d", 2, count)) + return errors.Errorf(errors.CodeCancelled, ctx, "cancelled", "Expected count of %d, got %d", 2, count) } return nil }) @@ -2135,8 +2143,8 @@ func TestObjectMapWithNilKeyValue(t *testing.T) { return } - if err.First().Code() != errors.CodeNull { - t.Errorf("Expected error code to be CodeNull, got: %s", err.First().Code()) + if err.Code() != errors.CodeNull { + t.Errorf("Expected error code to be CodeNull, got: %s", err.Code()) } } diff --git a/pkg/rules/rule.go b/pkg/rules/rule.go index 3beb5f8..716a638 100644 --- a/pkg/rules/rule.go +++ b/pkg/rules/rule.go @@ -36,14 +36,14 @@ type Rule[T any] interface { fmt.Stringer // Evaluate takes in a context and value and returns any validation errors. - Evaluate(ctx context.Context, value T) errors.ValidationErrorCollection + Evaluate(ctx context.Context, value T) errors.ValidationError } // RuleFunc implements the Rule interface for functions. -type RuleFunc[T any] func(ctx context.Context, value T) errors.ValidationErrorCollection +type RuleFunc[T any] func(ctx context.Context, value T) errors.ValidationError // Evaluate calls the rule function and returns the results. -func (rule RuleFunc[T]) Evaluate(ctx context.Context, value T) errors.ValidationErrorCollection { +func (rule RuleFunc[T]) Evaluate(ctx context.Context, value T) errors.ValidationError { return rule(ctx, value) } diff --git a/pkg/rules/rule_maxlen.go b/pkg/rules/rule_maxlen.go index df2522b..0b15375 100644 --- a/pkg/rules/rule_maxlen.go +++ b/pkg/rules/rule_maxlen.go @@ -13,11 +13,9 @@ type maxLenRule[TV any, T lengthy[TV]] struct { } // Evaluate takes a context and array/slice value and returns an error if it is not equal or lower in length than the specified value. -func (rule *maxLenRule[TV, T]) Evaluate(ctx context.Context, value T) errors.ValidationErrorCollection { +func (rule *maxLenRule[TV, T]) Evaluate(ctx context.Context, value T) errors.ValidationError { if len(value) > rule.max { - return errors.Collection( - errors.Error(errors.CodeMaxLen, ctx, rule.max), - ) + return errors.Error(errors.CodeMaxLen, ctx, rule.max) } return nil } diff --git a/pkg/rules/rule_maxlen_test.go b/pkg/rules/rule_maxlen_test.go index 5fb8d4e..9cb4c02 100644 --- a/pkg/rules/rule_maxlen_test.go +++ b/pkg/rules/rule_maxlen_test.go @@ -35,8 +35,8 @@ func TestSlice_MaxLen(t *testing.T) { err = ruleSet.Apply(context.TODO(), []int{1, 2, 3}, &output) if err == nil { t.Errorf("Expected error to not be nil") - } else if len(err) != 1 { - t.Errorf("Expected 1 error, got %d", len(err)) + } else if len(errors.Unwrap(err)) != 1 { + t.Errorf("Expected 1 error, got %d", len(errors.Unwrap(err))) } } diff --git a/pkg/rules/rule_minlen.go b/pkg/rules/rule_minlen.go index 02f3609..1d57ed9 100644 --- a/pkg/rules/rule_minlen.go +++ b/pkg/rules/rule_minlen.go @@ -13,11 +13,9 @@ type minLenRule[TV any, T lengthy[TV]] struct { } // Evaluate takes a context and array/slice value and returns an error if it is not equal or lower in length than the specified value. -func (rule *minLenRule[TV, T]) Evaluate(ctx context.Context, value T) errors.ValidationErrorCollection { +func (rule *minLenRule[TV, T]) Evaluate(ctx context.Context, value T) errors.ValidationError { if len(value) < rule.min { - return errors.Collection( - errors.Error(errors.CodeMinLen, ctx, rule.min), - ) + return errors.Error(errors.CodeMinLen, ctx, rule.min) } return nil } diff --git a/pkg/rules/rule_minlen_test.go b/pkg/rules/rule_minlen_test.go index 21bc6e1..5727c56 100644 --- a/pkg/rules/rule_minlen_test.go +++ b/pkg/rules/rule_minlen_test.go @@ -35,8 +35,8 @@ func TestSlice_MinLen(t *testing.T) { err = ruleSet.Apply(context.TODO(), []int{1}, &output) if err == nil { t.Errorf("Expected error to not be nil") - } else if len(err) != 1 { - t.Errorf("Expected 1 error, got %d", len(err)) + } else if len(errors.Unwrap(err)) != 1 { + t.Errorf("Expected 1 error, got %d", len(errors.Unwrap(err))) } } diff --git a/pkg/rules/ruleset.go b/pkg/rules/ruleset.go index fa20955..3bbfc5d 100644 --- a/pkg/rules/ruleset.go +++ b/pkg/rules/ruleset.go @@ -17,8 +17,17 @@ import ( // - WithErrorCallback(fn errors.ErrorCallback) type RuleSet[T any] interface { Rule[T] - Apply(ctx context.Context, value any, out any) errors.ValidationErrorCollection // Apply attempts to coerce the value into the correct type and evaluates all rules in the rule set, then assigns the results to an interface. - Any() RuleSet[any] // Any returns an implementation of rule sets for the "any" type that wraps a typed RuleSet so that the set can be used in nested objects and arrays. - Required() bool // Returns true if the value is not allowed to be omitted when nested under other rule sets. - String() string // Converts the rule set to a string for printing and debugging. + + // Apply coerces value into the correct type, evaluates all rules in the rule set, and assigns the result to out. + // Returns a ValidationError if coercion or validation fails. out must be a non-nil pointer to the output type. + Apply(ctx context.Context, value any, out any) errors.ValidationError + + // Any returns a RuleSet[any] that wraps this rule set for use in nested objects and arrays. + Any() RuleSet[any] + + // Required returns true if the value must be present when nested under other rule sets (e.g. required field). + Required() bool + + // String returns a string representation of the rule set for debugging and serialization. + String() string } diff --git a/pkg/rules/slice.go b/pkg/rules/slice.go index edf5fbe..61945d4 100644 --- a/pkg/rules/slice.go +++ b/pkg/rules/slice.go @@ -136,51 +136,39 @@ func (v *SliceRuleSet[T]) WithItemRuleSet(itemRules RuleSet[T]) *SliceRuleSet[T] return newRuleSet } -// finishApply merges coercion errors, applies top-level rules, and returns the final error collection. -func (v *SliceRuleSet[T]) finishApply(ctx context.Context, outputItems []T, itemErrors errors.ValidationErrorCollection, coercionErrors []errors.ValidationErrorCollection) errors.ValidationErrorCollection { - // Merge coercion errors if any - allErrors := itemErrors - if len(coercionErrors) > 0 { - for _, ce := range coercionErrors { - if ce != nil { - allErrors = append(allErrors, ce...) - } +// finishApply merges coercion errors, applies top-level rules, and returns the final error. +func (v *SliceRuleSet[T]) finishApply(ctx context.Context, outputItems []T, itemErrors errors.ValidationError, coercionErrors []errors.ValidationError) errors.ValidationError { + var errs errors.ValidationError + if itemErrors != nil { + errs = errors.Join(errs, itemErrors) + } + for _, ce := range coercionErrors { + if ce != nil { + errs = errors.Join(errs, ce) } } - - // Check minLen - minLen is checked at the end after all items are processed - // minLen is copied to clones, so we only need to check the current rule set - // outputItems will be non-nil if minLen > 0 (we allocate it in applyChan) if v.minLen > 0 { actualLen := len(outputItems) if actualLen < v.minLen { - allErrors = append(allErrors, errors.Error( - errors.CodeMinLen, ctx, v.minLen, - )) + errs = errors.Join(errs, errors.Error(errors.CodeMinLen, ctx, v.minLen)) } } - - // Apply top-level rules on collected output if len(outputItems) > 0 { for currentRuleSet := v; currentRuleSet != nil; currentRuleSet = currentRuleSet.parent { if currentRuleSet.rule != nil { if err := currentRuleSet.rule.Evaluate(ctx, outputItems); err != nil { - allErrors = append(allErrors, err...) + errs = errors.Join(errs, err) } } } } - - if len(allErrors) != 0 { - return allErrors - } - return nil + return errs } // newInputChan converts a slice or array to a channel and returns the channel, original items, and coercion errors. // originalItems is populated when itemRuleSet exists, allowing it to process items that couldn't be cast to T. // coercionErrors is populated when no itemRuleSet exists, tracking items that couldn't be cast. -func (v *SliceRuleSet[T]) newInputChan(ctx context.Context, valueOf reflect.Value) (<-chan T, []any, []errors.ValidationErrorCollection) { +func (v *SliceRuleSet[T]) newInputChan(ctx context.Context, valueOf reflect.Value) (<-chan T, []any, []errors.ValidationError) { // Convert slice/array to channel // Note: maxLen is checked at the end as a top-level rule (after all items are processed) // Send all items - if they can't be cast to T, send zero value @@ -196,7 +184,7 @@ func (v *SliceRuleSet[T]) newInputChan(ctx context.Context, valueOf reflect.Valu } var originalItems []any - var coercionErrors []errors.ValidationErrorCollection + var coercionErrors []errors.ValidationError // If we have itemRuleSet, track original items for items that can't be cast if itemRuleSet != nil { @@ -236,7 +224,7 @@ func (v *SliceRuleSet[T]) newInputChan(ctx context.Context, valueOf reflect.Valu if _, ok := itemInterface.(T); !ok { subContext := rulecontext.WithPathString(ctx, strconv.Itoa(i)) actual := item.Kind().String() - coercionErrors = append(coercionErrors, errors.Collection(errors.Error(errors.CodeType, subContext, expectedType.Name(), actual))) + coercionErrors = append(coercionErrors, errors.Error(errors.CodeType, subContext, expectedType.Name(), actual)) } } } @@ -251,8 +239,8 @@ func (v *SliceRuleSet[T]) newInputChan(ctx context.Context, valueOf reflect.Valu // (used when itemRuleSet needs to process original items that couldn't be cast to T) // applyChan does NOT close channels - they are managed by the caller. // applyChan returns the collected items and errors. Top-level rules are NOT applied here. -func (v *SliceRuleSet[T]) applyChan(ctx context.Context, input <-chan T, output chan<- T, originalItems []any) ([]T, errors.ValidationErrorCollection) { - var allErrors = errors.Collection() +func (v *SliceRuleSet[T]) applyChan(ctx context.Context, input <-chan T, output chan<- T, originalItems []any) ([]T, errors.ValidationError) { + var errs errors.ValidationError var outputItems []T var index int @@ -288,42 +276,32 @@ func (v *SliceRuleSet[T]) applyChan(ctx context.Context, input <-chan T, output for { select { case <-ctx.Done(): - allErrors = append(allErrors, contextErrorToValidation(ctx)) - return outputItems, allErrors + errs = errors.Join(errs, contextErrorToValidation(ctx)) + return outputItems, errs case item, ok := <-input: if !ok { - // Input channel closed - all items processed - // Return items and errors (top-level rules will be applied in Apply) - return outputItems, allErrors + return outputItems, errs } // Check maxLen proactively - stop applying item rules after maxLen // Item rules are applied up to maxLen, after which we stop processing items if maxLen > 0 && index >= maxLen { - // Max length exceeded - return immediately with error - // Don't drain the channel as it may never close (DoS risk) - allErrors = append(allErrors, errors.Error( - errors.CodeMaxLen, ctx, maxLen, - )) - return outputItems, allErrors + errs = errors.Join(errs, errors.Error(errors.CodeMaxLen, ctx, maxLen)) + return outputItems, errs } - // Validate item (only if we haven't exceeded maxLen) var itemOutput T - var itemErr errors.ValidationErrorCollection - + var itemErr errors.ValidationError if itemRuleSet != nil { subContext := rulecontext.WithPathIndex(ctx, index) - // Use original item if available (for items that couldn't be cast to T) var itemInput any = item if originalItems != nil && index < len(originalItems) && originalItems[index] != nil { itemInput = originalItems[index] } itemErr = itemRuleSet.Apply(subContext, itemInput, &itemOutput) if itemErr != nil { - // Try to use original item if validation fails itemOutput = item - allErrors = append(allErrors, itemErr...) + errs = errors.Join(errs, itemErr) } } else { // No item rules @@ -333,8 +311,8 @@ func (v *SliceRuleSet[T]) applyChan(ctx context.Context, input <-chan T, output // Write to output channel immediately select { case <-ctx.Done(): - allErrors = append(allErrors, contextErrorToValidation(ctx)) - return outputItems, allErrors + errs = errors.Join(errs, contextErrorToValidation(ctx)) + return outputItems, errs case output <- itemOutput: // Append to outputItems if we need it for top-level rules or minLen if hasTopLevelRules || v.minLen > 0 { @@ -347,14 +325,14 @@ func (v *SliceRuleSet[T]) applyChan(ctx context.Context, input <-chan T, output } // Apply performs validation of a RuleSet against a value and assigns the result to the output parameter. -// Apply returns a ValidationErrorCollection if any validation errors occur. +// Apply returns a ValidationError if any validation errors occur. // // Apply supports channels as both input and output. When using channels: // - Input channel: reads values until closed, max length is hit, or context times out // - Output channel: writes validated values in the same order as input // - All errors are collected and returned at once // - Items are streamed (validated and written immediately, not collected upfront) -func (v *SliceRuleSet[T]) Apply(ctx context.Context, input any, output any) errors.ValidationErrorCollection { +func (v *SliceRuleSet[T]) Apply(ctx context.Context, input any, output any) errors.ValidationError { // Add error config to context for error customization ctx = errors.WithErrorConfig(ctx, v.errorConfig) @@ -366,9 +344,7 @@ func (v *SliceRuleSet[T]) Apply(ctx context.Context, input any, output any) erro // Ensure output is a non-nil pointer outputVal := reflect.ValueOf(output) if outputVal.Kind() != reflect.Ptr || outputVal.IsNil() { - return errors.Collection(errors.Errorf( - errors.CodeInternal, ctx, "internal error", "Output must be a non-nil pointer", - )) + return errors.Errorf(errors.CodeInternal, ctx, "internal error", "Output must be a non-nil pointer") } outputElem := outputVal.Elem() @@ -382,38 +358,26 @@ func (v *SliceRuleSet[T]) Apply(ctx context.Context, input any, output any) erro case reflect.Chan: // Validate channel element type if outputElem.IsNil() { - return errors.Collection(errors.Errorf( - errors.CodeInternal, ctx, "internal error", "Output channel cannot be nil", - )) + return errors.Errorf(errors.CodeInternal, ctx, "internal error", "Output channel cannot be nil") } actualType := outputElem.Type().Elem() if !actualType.AssignableTo(expectedType) { - return errors.Collection(errors.Errorf( - errors.CodeInternal, ctx, "internal error", "Output channel element type %s is not compatible with %s", - actualType.String(), expectedType.String(), - )) + return errors.Errorf(errors.CodeInternal, ctx, "internal error", "Output channel element type %s is not compatible with %s", actualType.String(), expectedType.String()) } case reflect.Interface: // Interface output: check if []T is assignable to the interface type // If nil, it's valid (we'll set it). If not nil, check assignability. if !outputElem.IsNil() { if !expectedSliceType.AssignableTo(outputElem.Type()) { - return errors.Collection(errors.Errorf( - errors.CodeInternal, ctx, "internal error", "Cannot assign %T to %T", []T(nil), outputElem.Interface(), - )) + return errors.Errorf(errors.CodeInternal, ctx, "internal error", "Cannot assign %T to %T", []T(nil), outputElem.Interface()) } } case reflect.Slice: - // Validate slice element type - check if []T is assignable to output slice type if !expectedSliceType.AssignableTo(outputElem.Type()) { - return errors.Collection(errors.Errorf( - errors.CodeInternal, ctx, "internal error", "Cannot assign %T to %T", []T(nil), outputElem.Interface(), - )) + return errors.Errorf(errors.CodeInternal, ctx, "internal error", "Cannot assign %T to %T", []T(nil), outputElem.Interface()) } default: - return errors.Collection(errors.Errorf( - errors.CodeInternal, ctx, "internal error", "Output must be a slice or channel, got %s", outputElemKind, - )) + return errors.Errorf(errors.CodeInternal, ctx, "internal error", "Output must be a slice or channel, got %s", outputElemKind) } valueOf := reflect.ValueOf(input) @@ -422,7 +386,7 @@ func (v *SliceRuleSet[T]) Apply(ctx context.Context, input any, output any) erro // Determine input channel var inputChan <-chan T - var coercionErrors []errors.ValidationErrorCollection + var coercionErrors []errors.ValidationError var originalItems []any switch inputKind { @@ -430,9 +394,7 @@ func (v *SliceRuleSet[T]) Apply(ctx context.Context, input any, output any) erro // Input is already a channel inputVal := reflect.ValueOf(input) if inputVal.IsNil() { - return errors.Collection(errors.Errorf( - errors.CodeInternal, ctx, "internal error", "Input channel cannot be nil", - )) + return errors.Errorf(errors.CodeInternal, ctx, "internal error", "Input channel cannot be nil") } // Convert to receive-only channel @@ -443,18 +405,15 @@ func (v *SliceRuleSet[T]) Apply(ctx context.Context, input any, output any) erro case chan T: recvChan = ch default: - // Type assertion failed expectedType := reflect.TypeOf((*T)(nil)).Elem() actualType := inputVal.Type().Elem() - return errors.Collection(errors.Error(errors.CodeType, - ctx, expectedType.String(), actualType.String(), - )) + return errors.Error(errors.CodeType, ctx, expectedType.String(), actualType.String()) } inputChan = recvChan case reflect.Slice, reflect.Array: inputChan, originalItems, coercionErrors = v.newInputChan(ctx, valueOf) default: - return errors.Collection(errors.Error(errors.CodeType, ctx, "array", inputKind.String())) + return errors.Error(errors.CodeType, ctx, "array", inputKind.String()) } // Determine output channel and setup @@ -475,10 +434,7 @@ func (v *SliceRuleSet[T]) Apply(ctx context.Context, input any, output any) erro case chan T: sendChan = ch default: - // Should not happen - we validated earlier, but handle gracefully - return errors.Collection(errors.Errorf( - errors.CodeInternal, ctx, "internal error", "Output channel type assertion failed", - )) + return errors.Errorf(errors.CodeInternal, ctx, "internal error", "Output channel type assertion failed") } outputChan = sendChan closeOutputChan = false // Caller manages the channel @@ -537,8 +493,8 @@ func (v *SliceRuleSet[T]) Apply(ctx context.Context, input any, output any) erro return v.finishApply(ctx, outputItems, itemErrors, coercionErrors) } -// Evaluate performs validation of a RuleSet against a slice type and returns a ValidationErrorCollection. -func (ruleSet *SliceRuleSet[T]) Evaluate(ctx context.Context, value []T) errors.ValidationErrorCollection { +// Evaluate performs validation of a RuleSet against a slice type and returns a ValidationError. +func (ruleSet *SliceRuleSet[T]) Evaluate(ctx context.Context, value []T) errors.ValidationError { var out any return ruleSet.Apply(ctx, value, &out) } diff --git a/pkg/rules/slice_interface_test.go b/pkg/rules/slice_interface_test.go index ff0c3a1..32537d0 100644 --- a/pkg/rules/slice_interface_test.go +++ b/pkg/rules/slice_interface_test.go @@ -19,7 +19,7 @@ type InterfaceTest struct { // - Can cast to interface. func TestInterfaceSlice(t *testing.T) { innerRuleSet := rules.Interface[MyTestInterface](). - WithCast(func(ctx context.Context, v any) (MyTestInterface, errors.ValidationErrorCollection) { + WithCast(func(ctx context.Context, v any) (MyTestInterface, errors.ValidationError) { if v == nil { return nil, nil } diff --git a/pkg/rules/slice_test.go b/pkg/rules/slice_test.go index 049010b..25a3014 100644 --- a/pkg/rules/slice_test.go +++ b/pkg/rules/slice_test.go @@ -49,7 +49,7 @@ func TestSliceRuleSet_Apply_TypeError(t *testing.T) { // Apply with an invalid input type, expecting an error err := rules.Slice[string]().Apply(context.TODO(), 123, &output) - if len(err) == 0 { + if len(errors.Unwrap(err)) == 0 { t.Error("Expected errors to not be empty") return } @@ -77,7 +77,7 @@ func TestSliceItemCastError(t *testing.T) { // Apply with an array of incorrect types, expecting an error err := rules.Slice[string]().Apply(context.TODO(), []int{1, 2, 3}, &output) - if len(err) == 0 { + if len(errors.Unwrap(err)) == 0 { t.Errorf("Expected errors to not be empty.") return } @@ -91,8 +91,8 @@ func TestSliceRuleSet_Apply_WithItemRuleSetError(t *testing.T) { // Apply with a valid array but with an item rule set that will fail, expecting 2 errors err := rules.Slice[string]().WithItemRuleSet(rules.String().WithMinLen(2)).Apply(context.TODO(), []string{"", "a", "ab", "abc"}, &output) - if len(err) != 2 { - t.Errorf("Expected 2 errors and got %d.", len(err)) + if len(errors.Unwrap(err)) != 2 { + t.Errorf("Expected 2 errors and got %d.", len(errors.Unwrap(err))) return } } @@ -123,8 +123,8 @@ func TestSliceRuleSet_WithRuleFunc(t *testing.T) { return } - if len(err) != 2 { - t.Errorf("Expected 2 errors, got %d", len(err)) + if len(errors.Unwrap(err)) != 2 { + t.Errorf("Expected 2 errors, got %d", len(errors.Unwrap(err))) return } @@ -149,29 +149,29 @@ func TestSliceRuleSet_Apply_ReturnsCorrectPaths(t *testing.T) { if err == nil { t.Errorf("Expected errors to not be nil") - } else if len(err) != 2 { - t.Errorf("Expected 2 errors got %d: %s", len(err), err.Error()) + } else if len(errors.Unwrap(err)) != 2 { + t.Errorf("Expected 2 errors got %d: %s", len(errors.Unwrap(err)), err.Error()) return } // Check for the first error path (/myarray/0) - errA := err.For("/myarray/0") + errA := errors.For(err, "/myarray/0") if errA == nil { t.Errorf("Expected error for /myarray/0 to not be nil") - } else if len(errA) != 1 { - t.Errorf("Expected exactly 1 error for /myarray/0 got %d", len(errA)) - } else if errA.First().Path() != "/myarray/0" { - t.Errorf("Expected error path to be `%s` got `%s`", "/myarray/0", errA.First().Path()) + } else if len(errors.Unwrap(errA)) != 1 { + t.Errorf("Expected exactly 1 error for /myarray/0 got %d", len(errors.Unwrap(errA))) + } else if errA.Path() != "/myarray/0" { + t.Errorf("Expected error path to be `%s` got `%s`", "/myarray/0", errA.Path()) } // Check for the second error path (/myarray/1) - errC := err.For("/myarray/1") + errC := errors.For(err, "/myarray/1") if errC == nil { t.Errorf("Expected error for /myarray/1 to not be nil") - } else if len(errC) != 1 { - t.Errorf("Expected exactly 1 error for /myarray/1 got %d", len(errC)) - } else if errC.First().Path() != "/myarray/1" { - t.Errorf("Expected error path to be `%s` got `%s`", "/myarray/1", errC.First().Path()) + } else if len(errors.Unwrap(errC)) != 1 { + t.Errorf("Expected exactly 1 error for /myarray/1 got %d", len(errors.Unwrap(errC))) + } else if errC.Path() != "/myarray/1" { + t.Errorf("Expected error path to be `%s` got `%s`", "/myarray/1", errC.Path()) } } @@ -375,7 +375,7 @@ func TestSliceRuleSet_Apply_ChannelWithTimeout(t *testing.T) { } // Check that we got a timeout error - if len(err) == 0 { + if len(errors.Unwrap(err)) == 0 { t.Error("Expected at least one error") return } @@ -407,8 +407,8 @@ func TestSliceRuleSet_Apply_ChannelWithItemRuleSet(t *testing.T) { return } - if len(err) != 2 { - t.Errorf("Expected 2 errors (for 'a' and ''), got %d", len(err)) + if len(errors.Unwrap(err)) != 2 { + t.Errorf("Expected 2 errors (for 'a' and ''), got %d", len(errors.Unwrap(err))) } // Check that output has all 4 items (even invalid ones are included) @@ -518,7 +518,7 @@ func TestSliceRuleSet_Apply_ChannelTypeCompatibility(t *testing.T) { return } - if len(err) == 0 { + if len(errors.Unwrap(err)) == 0 { t.Error("Expected at least one error") return } @@ -625,7 +625,7 @@ func TestSliceRuleSet_Apply_ChannelWithCancellation(t *testing.T) { } // Check that we got a cancellation error - if len(err) == 0 { + if len(errors.Unwrap(err)) == 0 { t.Error("Expected at least one error") return } @@ -687,7 +687,7 @@ func TestSliceRuleSet_Apply_ContextCancelledDuringValidation(t *testing.T) { // Create a rule set that will take time to validate // Cancel after validation starts but before it completes ruleSet := rules.Slice[string]().WithItemRuleSet( - rules.String().WithRuleFunc(func(_ context.Context, s string) errors.ValidationErrorCollection { + rules.String().WithRuleFunc(func(_ context.Context, s string) errors.ValidationError { // Cancel context after first item is processed if s == "test" { time.Sleep(10 * time.Millisecond) @@ -710,13 +710,13 @@ func TestSliceRuleSet_Apply_ContextCancelledDuringValidation(t *testing.T) { } // Check that we got a cancellation error - if len(err) == 0 { + if len(errors.Unwrap(err)) == 0 { t.Error("Expected at least one error") return } // Verify cancellation error code - firstErr := err.First() + firstErr := err if firstErr == nil { t.Error("Expected at least one error") return @@ -833,8 +833,8 @@ func TestSliceRuleSet_Apply_ChannelOutputWithPartialErrors(t *testing.T) { return } - if len(err) != 2 { - t.Errorf("Expected 2 errors (for 'a' and ''), got %d", len(err)) + if len(errors.Unwrap(err)) != 2 { + t.Errorf("Expected 2 errors (for 'a' and ''), got %d", len(errors.Unwrap(err))) } // Read from output channel - should have all 4 items @@ -860,8 +860,10 @@ func TestSliceRuleSet_Apply_ChannelOutputWithPartialErrors(t *testing.T) { // Verify errors are for the correct items errPaths := make(map[string]bool) - for _, e := range err { - errPaths[e.Path()] = true + for _, e := range errors.Unwrap(err) { + if ve, ok := e.(errors.ValidationError); ok { + errPaths[ve.Path()] = true + } } // Should have errors at indices 1 and 3 @@ -888,7 +890,7 @@ func TestSliceRuleSet_Apply_ChannelOutput_NilOutput(t *testing.T) { return } - if len(err) == 0 { + if len(errors.Unwrap(err)) == 0 { t.Error("Expected at least one error") return } @@ -954,7 +956,7 @@ func TestSliceRuleSet_Apply_ChannelOutput_NilChannel(t *testing.T) { return } - if len(err) == 0 { + if len(errors.Unwrap(err)) == 0 { t.Error("Expected at least one error") return } @@ -982,7 +984,7 @@ func TestSliceRuleSet_Apply_ChannelOutput_ReceiveOnly(t *testing.T) { return } - if len(err) == 0 { + if len(errors.Unwrap(err)) == 0 { t.Error("Expected at least one error") return } @@ -1004,7 +1006,7 @@ func TestSliceRuleSet_Apply_ChannelOutput_IncompatibleType(t *testing.T) { return } - if len(err) == 0 { + if len(errors.Unwrap(err)) == 0 { t.Error("Expected at least one error") return } @@ -1173,13 +1175,13 @@ func TestSliceRuleSet_Apply_PutIndexError(t *testing.T) { } // Verify we got a cancellation error - if len(err) == 0 { + if len(errors.Unwrap(err)) == 0 { t.Error("Expected at least one error") return } // Verify error is a cancellation error - firstErr := err.First() + firstErr := err if firstErr == nil { t.Error("Expected at least one error") return @@ -1203,7 +1205,7 @@ func TestSliceRuleSet_Apply_FinalizeError(t *testing.T) { return } - if len(err) == 0 { + if len(errors.Unwrap(err)) == 0 { t.Error("Expected at least one error") return } @@ -1226,7 +1228,7 @@ func TestSliceRuleSet_Apply_ContextCancelledDuringProcessing(t *testing.T) { // Create rule set with item rule function that cancels after first item // Use closure to capture cancel function ruleSet := rules.Slice[string]().WithItemRuleSet( - rules.String().WithRuleFunc(func(_ context.Context, s string) errors.ValidationErrorCollection { + rules.String().WithRuleFunc(func(_ context.Context, s string) errors.ValidationError { // Cancel context after first item is processed if s == "a" { cancel() @@ -1338,7 +1340,7 @@ func TestSliceRuleSet_Apply_NonNilInterfaceNotAssignable(t *testing.T) { } // Verify error code - firstErr := err.First() + firstErr := err if firstErr == nil { t.Error("Expected at least one error") return @@ -1365,7 +1367,7 @@ func TestSliceRuleSet_Apply_SliceNotAssignable(t *testing.T) { } // Verify error code - firstErr := err.First() + firstErr := err if firstErr == nil { t.Error("Expected at least one error") return @@ -1500,7 +1502,7 @@ func TestSliceRuleSet_Apply_SliceOutputAdapter_FinalizeTrim(t *testing.T) { // Use closure-based cancellation - items are validated sequentially // so cancellation on first item ("a") should always be detected ruleSet := rules.Slice[string]().WithItemRuleSet( - rules.String().WithRuleFunc(func(_ context.Context, s string) errors.ValidationErrorCollection { + rules.String().WithRuleFunc(func(_ context.Context, s string) errors.ValidationError { // Cancel after first item is processed if s == "a" { cancel() @@ -1519,7 +1521,7 @@ func TestSliceRuleSet_Apply_SliceOutputAdapter_FinalizeTrim(t *testing.T) { } // Verify it's a cancellation error - firstErr := err.First() + firstErr := err if firstErr == nil { t.Error("Expected at least one error") return @@ -1579,7 +1581,7 @@ func TestSliceRuleSet_Apply_FinalizeErrorAtEnd(t *testing.T) { return } - if len(err) == 0 { + if len(errors.Unwrap(err)) == 0 { t.Error("Expected at least one error") return } @@ -1705,7 +1707,7 @@ func TestSliceRuleSet_Apply_ContextCancelledBetweenItems_NonChanInput(t *testing // Create rule set with item rule function that cancels after first item // Use closure to capture cancel function ruleSet := rules.Slice[string]().WithItemRuleSet( - rules.String().WithRuleFunc(func(itemCtx context.Context, s string) errors.ValidationErrorCollection { + rules.String().WithRuleFunc(func(itemCtx context.Context, s string) errors.ValidationError { mu.Lock() processedItems = append(processedItems, s) mu.Unlock() @@ -1730,7 +1732,7 @@ func TestSliceRuleSet_Apply_ContextCancelledBetweenItems_NonChanInput(t *testing } // Verify cancellation error code - firstErr := err.First() + firstErr := err if firstErr == nil { t.Error("Expected at least one error") return diff --git a/pkg/rules/string.go b/pkg/rules/string.go index 482b5e5..722b59c 100644 --- a/pkg/rules/string.go +++ b/pkg/rules/string.go @@ -127,8 +127,8 @@ func (v *StringRuleSet) WithNil() *StringRuleSet { } // Apply performs validation of a RuleSet against a value and assigns the resulting string to the output pointer. -// Apply returns a ValidationErrorCollection. -func (v *StringRuleSet) Apply(ctx context.Context, value, output any) errors.ValidationErrorCollection { +// Apply returns a ValidationError. +func (v *StringRuleSet) Apply(ctx context.Context, value, output any) errors.ValidationError { // Add error config to context for error customization ctx = errors.WithErrorConfig(ctx, v.errorConfig) @@ -140,16 +140,14 @@ func (v *StringRuleSet) Apply(ctx context.Context, value, output any) errors.Val // Ensure output is a pointer that can be set rv := reflect.ValueOf(output) if rv.Kind() != reflect.Ptr || rv.IsNil() { - return errors.Collection( - errors.Errorf(errors.CodeInternal, ctx, "internal error", "Output must be a non-nil pointer"), - ) + return errors.Errorf(errors.CodeInternal, ctx, "internal error", "Output must be a non-nil pointer") } // Attempt to coerce the input to a string str, validationErr := v.coerce(value, ctx) if validationErr != nil { - return errors.Collection(validationErr) + return validationErr } verrs := v.Evaluate(ctx, str) @@ -173,33 +171,23 @@ func (v *StringRuleSet) Apply(ctx context.Context, value, output any) errors.Val return nil } - return errors.Collection( - errors.Errorf(errors.CodeInternal, ctx, "internal error", "Cannot assign string to %T", output), - ) + return errors.Errorf(errors.CodeInternal, ctx, "internal error", "Cannot assign string to %T", output) } -// Evaluate performs validation of a RuleSet against a string value and returns a ValidationErrorCollection. -func (v *StringRuleSet) Evaluate(ctx context.Context, value string) errors.ValidationErrorCollection { - allErrors := errors.Collection() - +// Evaluate performs validation of a RuleSet against a string value and returns a ValidationError. +func (v *StringRuleSet) Evaluate(ctx context.Context, value string) errors.ValidationError { + var errs errors.ValidationError currentRuleSet := v ctx = rulecontext.WithRuleSet(ctx, v) - for currentRuleSet != nil { if currentRuleSet.rule != nil { - if errs := currentRuleSet.rule.Evaluate(ctx, value); errs != nil { - allErrors = append(allErrors, errs...) + if e := currentRuleSet.rule.Evaluate(ctx, value); e != nil { + errs = errors.Join(errs, e) } } - currentRuleSet = currentRuleSet.parent } - - if len(allErrors) > 0 { - return allErrors - } else { - return nil - } + return errs } // noConflict returns the new array rule set with all conflicting rules removed. diff --git a/pkg/rules/string_rule_max.go b/pkg/rules/string_rule_max.go index a1494ba..ba86763 100644 --- a/pkg/rules/string_rule_max.go +++ b/pkg/rules/string_rule_max.go @@ -14,11 +14,9 @@ type stringMaxRule struct { } // Evaluate takes a context and string value and returns an error if it is lexicographically greater than the specified maximum value. -func (rule *stringMaxRule) Evaluate(ctx context.Context, value string) errors.ValidationErrorCollection { +func (rule *stringMaxRule) Evaluate(ctx context.Context, value string) errors.ValidationError { if value > rule.max { - return errors.Collection( - errors.Error(errors.CodeMax, ctx, util.TruncateString(rule.max)), - ) + return errors.Error(errors.CodeMax, ctx, util.TruncateString(rule.max)) } return nil diff --git a/pkg/rules/string_rule_max_test.go b/pkg/rules/string_rule_max_test.go index cd3a60f..61e6297 100644 --- a/pkg/rules/string_rule_max_test.go +++ b/pkg/rules/string_rule_max_test.go @@ -112,7 +112,7 @@ func TestStringRuleSet_WithMax_Truncation(t *testing.T) { return } - errMsg := err[0].Error() + errMsg := err.Error() // The error message should contain the truncated string (50 chars + "...") // We check that it doesn't contain the full 101-character string if len(errMsg) > 200 { diff --git a/pkg/rules/string_rule_maxexclusive.go b/pkg/rules/string_rule_maxexclusive.go index 6fcd945..4051d2d 100644 --- a/pkg/rules/string_rule_maxexclusive.go +++ b/pkg/rules/string_rule_maxexclusive.go @@ -14,11 +14,9 @@ type stringMaxExclusiveRule struct { } // Evaluate takes a context and string value and returns an error if it is lexicographically greater than or equal to the specified value. -func (rule *stringMaxExclusiveRule) Evaluate(ctx context.Context, value string) errors.ValidationErrorCollection { +func (rule *stringMaxExclusiveRule) Evaluate(ctx context.Context, value string) errors.ValidationError { if value >= rule.max { - return errors.Collection( - errors.Error(errors.CodeMaxExclusive, ctx, util.TruncateString(rule.max)), - ) + return errors.Error(errors.CodeMaxExclusive, ctx, util.TruncateString(rule.max)) } return nil diff --git a/pkg/rules/string_rule_min.go b/pkg/rules/string_rule_min.go index eea8115..625f369 100644 --- a/pkg/rules/string_rule_min.go +++ b/pkg/rules/string_rule_min.go @@ -14,11 +14,9 @@ type stringMinRule struct { } // Evaluate takes a context and string value and returns an error if it is lexicographically less than the specified minimum value. -func (rule *stringMinRule) Evaluate(ctx context.Context, value string) errors.ValidationErrorCollection { +func (rule *stringMinRule) Evaluate(ctx context.Context, value string) errors.ValidationError { if value < rule.min { - return errors.Collection( - errors.Error(errors.CodeMin, ctx, util.TruncateString(rule.min)), - ) + return errors.Error(errors.CodeMin, ctx, util.TruncateString(rule.min)) } return nil diff --git a/pkg/rules/string_rule_min_test.go b/pkg/rules/string_rule_min_test.go index fa334ec..5304e91 100644 --- a/pkg/rules/string_rule_min_test.go +++ b/pkg/rules/string_rule_min_test.go @@ -111,7 +111,7 @@ func TestStringRuleSet_WithMin_Truncation(t *testing.T) { return } - errMsg := err[0].Error() + errMsg := err.Error() // The error message should contain the truncated string (50 chars + "...") // We check that it doesn't contain the full 101-character string if len(errMsg) > 200 { diff --git a/pkg/rules/string_rule_minexclusive.go b/pkg/rules/string_rule_minexclusive.go index 3e695a1..01bdf02 100644 --- a/pkg/rules/string_rule_minexclusive.go +++ b/pkg/rules/string_rule_minexclusive.go @@ -14,11 +14,9 @@ type stringMinExclusiveRule struct { } // Evaluate takes a context and string value and returns an error if it is lexicographically less than or equal to the specified value. -func (rule *stringMinExclusiveRule) Evaluate(ctx context.Context, value string) errors.ValidationErrorCollection { +func (rule *stringMinExclusiveRule) Evaluate(ctx context.Context, value string) errors.ValidationError { if value <= rule.min { - return errors.Collection( - errors.Error(errors.CodeMinExclusive, ctx, util.TruncateString(rule.min)), - ) + return errors.Error(errors.CodeMinExclusive, ctx, util.TruncateString(rule.min)) } return nil diff --git a/pkg/rules/string_rule_regex.go b/pkg/rules/string_rule_regex.go index 6747e0a..5aaacd8 100644 --- a/pkg/rules/string_rule_regex.go +++ b/pkg/rules/string_rule_regex.go @@ -17,11 +17,9 @@ type regexpRule struct { } // Evaluate takes a context and string value and returns an error if it does not match the expected pattern. -func (rule *regexpRule) Evaluate(ctx context.Context, value string) errors.ValidationErrorCollection { +func (rule *regexpRule) Evaluate(ctx context.Context, value string) errors.ValidationError { if !rule.exp.MatchString(value) { - return errors.Collection( - errors.Errorf(errors.CodePattern, ctx, "invalid format", rule.msg), - ) + return errors.Errorf(errors.CodePattern, ctx, "invalid format", rule.msg) } return nil diff --git a/pkg/rules/string_rule_values.go b/pkg/rules/string_rule_values.go index a862253..776966c 100644 --- a/pkg/rules/string_rule_values.go +++ b/pkg/rules/string_rule_values.go @@ -37,19 +37,15 @@ func (rule *stringValuesRule) exists(value string) bool { // Evaluate takes a context and string value and returns an error depending on whether the value is in a list // of allowed or denied values. -func (rule *stringValuesRule) Evaluate(ctx context.Context, value string) errors.ValidationErrorCollection { +func (rule *stringValuesRule) Evaluate(ctx context.Context, value string) errors.ValidationError { exists := rule.exists(value) if rule.allow { if !exists { - return errors.Collection( - errors.Error(errors.CodeNotAllowed, ctx), - ) + return errors.Error(errors.CodeNotAllowed, ctx) } } else if exists { - return errors.Collection( - errors.Error(errors.CodeForbidden, ctx), - ) + return errors.Error(errors.CodeForbidden, ctx) } return nil diff --git a/pkg/rules/string_test.go b/pkg/rules/string_test.go index 0a5f56f..4ff1e06 100644 --- a/pkg/rules/string_test.go +++ b/pkg/rules/string_test.go @@ -56,7 +56,7 @@ func TestStringRuleSet_Apply_TypeError(t *testing.T) { // Use Apply instead of Validate err := rules.String().WithStrict().Apply(context.TODO(), 123, &str) - if len(err) == 0 { + if len(errors.Unwrap(err)) == 0 { t.Error("Expected errors to not be empty") } } @@ -253,14 +253,14 @@ func TestStringRuleSet_ErrorConfig_CoercionError(t *testing.T) { WithStrict(). // Strict mode disables coercion WithErrorMessage("type error", "expected a string") - errs := ruleSet.Apply(context.Background(), 123, &out) + errs := errors.Unwrap(ruleSet.Apply(context.Background(), 123, &out)) if len(errs) == 0 { t.Fatal("Expected coercion error") } - if errs[0].ShortError() != "type error" { - t.Errorf("Expected short error 'type error', got: %s", errs[0].ShortError()) + if ve, ok := errs[0].(errors.ValidationError); ok && ve.ShortError() != "type error" { + t.Errorf("Expected short error 'type error', got: %s", ve.ShortError()) } } @@ -272,13 +272,13 @@ func TestStringRuleSet_ErrorConfig_WithMinLen(t *testing.T) { WithMinLen(5). WithErrorMessage("custom short", "custom long") - errs := ruleSet.Apply(context.Background(), "ab", &out) + errs := errors.Unwrap(ruleSet.Apply(context.Background(), "ab", &out)) if len(errs) == 0 { t.Fatal("Expected validation error") } - if errs[0].ShortError() != "custom short" { - t.Errorf("Expected short error 'custom short', got: %s", errs[0].ShortError()) + if ve, ok := errs[0].(errors.ValidationError); ok && ve.ShortError() != "custom short" { + t.Errorf("Expected short error 'custom short', got: %s", ve.ShortError()) } } diff --git a/pkg/rules/time/duration.go b/pkg/rules/time/duration.go index 420b25b..ecd4802 100644 --- a/pkg/rules/time/duration.go +++ b/pkg/rules/time/duration.go @@ -168,8 +168,8 @@ func (ruleSet *DurationRuleSet) WithRounding(rounding rules.Rounding) *DurationR } // Apply performs validation of a RuleSet against a value and assigns the result to the output parameter. -// Apply returns a ValidationErrorCollection if any validation errors occur. -func (ruleSet *DurationRuleSet) Apply(ctx context.Context, input any, output any) errors.ValidationErrorCollection { +// Apply returns a ValidationError if any validation errors occur. +func (ruleSet *DurationRuleSet) Apply(ctx context.Context, input any, output any) errors.ValidationError { // Add error config to context for error customization ctx = errors.WithErrorConfig(ctx, ruleSet.errorConfig) @@ -181,9 +181,7 @@ func (ruleSet *DurationRuleSet) Apply(ctx context.Context, input any, output any // Ensure output is a non-nil pointer outputVal := reflect.ValueOf(output) if outputVal.Kind() != reflect.Ptr || outputVal.IsNil() { - return errors.Collection(errors.Errorf( - errors.CodeInternal, ctx, "internal error", "Output must be a non-nil pointer", - )) + return errors.Errorf(errors.CodeInternal, ctx, "internal error", "Output must be a non-nil pointer") } var d time.Duration @@ -220,14 +218,14 @@ func (ruleSet *DurationRuleSet) Apply(ctx context.Context, input any, output any ok = true } else { // String parsing failed - this is a format/pattern error, not a type error - return errors.Collection(errors.Errorf(errors.CodePattern, ctx, "invalid format", "invalid duration format: %v", err)) + return errors.Errorf(errors.CodePattern, ctx, "invalid format", "invalid duration format: %v", err) } default: - return errors.Collection(errors.Error(errors.CodeType, ctx, "duration", reflect.TypeOf(input).String())) + return errors.Error(errors.CodeType, ctx, "duration", reflect.TypeOf(input).String()) } if !ok { - return errors.Collection(errors.Error(errors.CodeType, ctx, "duration", reflect.TypeOf(input).String())) + return errors.Error(errors.CodeType, ctx, "duration", reflect.TypeOf(input).String()) } // Handle setting the value in output @@ -249,9 +247,7 @@ func (ruleSet *DurationRuleSet) Apply(ctx context.Context, input any, output any // Only error if output is numeric (not duration) // Duration output can accept any value if actualOutputElem.Type() != reflect.TypeOf(d) { - return errors.Collection(errors.Errorf( - errors.CodeRange, ctx, "duration", "Duration %s is not evenly divisible by unit %s", d, unit, - )) + return errors.Errorf(errors.CodeRange, ctx, "duration", "Duration %s is not evenly divisible by unit %s", d, unit) } } else { // Apply rounding based on the remainder @@ -296,7 +292,7 @@ func (ruleSet *DurationRuleSet) Apply(ctx context.Context, input any, output any if outputKind >= reflect.Uint && outputKind <= reflect.Uintptr { // For unsigned types, check if value is non-negative and fits if quotient < 0 { - return errors.Collection(errors.NewRangeError(ctx, "duration")) + return errors.NewRangeError(ctx, "duration") } // Check if the value fits in the target type by converting and checking if we lose information targetType := actualOutputElem.Type() @@ -305,7 +301,7 @@ func (ruleSet *DurationRuleSet) Apply(ctx context.Context, input any, output any // Convert back to see if we lost information convertedBack := int64(testValue.Uint()) if convertedBack != quotient { - return errors.Collection(errors.NewRangeError(ctx, "duration")) + return errors.NewRangeError(ctx, "duration") } // Set the value - if output was an interface, set the new value into it if outputElem.Kind() == reflect.Interface { @@ -321,7 +317,7 @@ func (ruleSet *DurationRuleSet) Apply(ctx context.Context, input any, output any // Convert back to see if we lost information (same pattern as number_coerce.go) convertedBack := int64(testValue.Int()) if convertedBack != quotient { - return errors.Collection(errors.NewRangeError(ctx, "duration")) + return errors.NewRangeError(ctx, "duration") } // Set the value - if output was an interface, set the new value into it if outputElem.Kind() == reflect.Interface { @@ -339,37 +335,27 @@ func (ruleSet *DurationRuleSet) Apply(ctx context.Context, input any, output any // If the types are incompatible, return an error if !assignable { - return errors.Collection(errors.Errorf( - errors.CodeInternal, ctx, "internal error", "Cannot assign %T to %T", d, outputElem.Interface(), - )) + return errors.Errorf(errors.CodeInternal, ctx, "internal error", "Cannot assign %T to %T", d, outputElem.Interface()) } // Evaluate the duration value and return any validation errors return ruleSet.Evaluate(ctx, d) } -// Evaluate performs validation of a RuleSet against a time.Duration value and returns a ValidationErrorCollection. -func (ruleSet *DurationRuleSet) Evaluate(ctx context.Context, value time.Duration) errors.ValidationErrorCollection { - allErrors := errors.Collection() - +// Evaluate performs validation of a RuleSet against a time.Duration value and returns a ValidationError. +func (ruleSet *DurationRuleSet) Evaluate(ctx context.Context, value time.Duration) errors.ValidationError { + var errs errors.ValidationError currentRuleSet := ruleSet ctx = rulecontext.WithRuleSet(ctx, ruleSet) - for currentRuleSet != nil { if currentRuleSet.rule != nil { - if errs := currentRuleSet.rule.Evaluate(ctx, value); errs != nil { - allErrors = append(allErrors, errs...) + if e := currentRuleSet.rule.Evaluate(ctx, value); e != nil { + errs = errors.Join(errs, e) } } - currentRuleSet = currentRuleSet.parent } - - if len(allErrors) > 0 { - return allErrors - } else { - return nil - } + return errs } // noConflict returns the new duration rule set with all conflicting rules removed. diff --git a/pkg/rules/time/duration_private_test.go b/pkg/rules/time/duration_private_test.go index b45bea4..27bb27b 100644 --- a/pkg/rules/time/duration_private_test.go +++ b/pkg/rules/time/duration_private_test.go @@ -38,7 +38,7 @@ func TestDurationConflictType_Replaces_WrongType(t *testing.T) { // Test with a regular rule (not a ruleset) - cast should fail // RuleFunc doesn't implement the DurationRuleSet interface, so the cast in Replaces should fail - rule := rules.RuleFunc[time.Duration](func(ctx context.Context, value time.Duration) errors.ValidationErrorCollection { + rule := rules.RuleFunc[time.Duration](func(ctx context.Context, value time.Duration) errors.ValidationError { return nil }) if checker.Replaces(rule) { diff --git a/pkg/rules/time/rule_duration_max.go b/pkg/rules/time/rule_duration_max.go index 0a5402d..e6195b4 100644 --- a/pkg/rules/time/rule_duration_max.go +++ b/pkg/rules/time/rule_duration_max.go @@ -15,11 +15,9 @@ type maxDurationRule struct { } // Evaluate takes a context and duration value and returns an error if it is greater than the specified value. -func (rule *maxDurationRule) Evaluate(ctx context.Context, value time.Duration) errors.ValidationErrorCollection { +func (rule *maxDurationRule) Evaluate(ctx context.Context, value time.Duration) errors.ValidationError { if value > rule.max { - return errors.Collection( - errors.Errorf(errors.CodeMax, ctx, "above maximum", "must be at most %s", rule.max), - ) + return errors.Errorf(errors.CodeMax, ctx, "above maximum", "must be at most %s", rule.max) } return nil diff --git a/pkg/rules/time/rule_duration_maxexclusive.go b/pkg/rules/time/rule_duration_maxexclusive.go index 89691c6..32eb41e 100644 --- a/pkg/rules/time/rule_duration_maxexclusive.go +++ b/pkg/rules/time/rule_duration_maxexclusive.go @@ -15,12 +15,10 @@ type maxExclusiveDurationRule struct { } // Evaluate takes a context and duration value and returns an error if it is not less than the specified value (exclusive). -func (rule *maxExclusiveDurationRule) Evaluate(ctx context.Context, value time.Duration) errors.ValidationErrorCollection { +func (rule *maxExclusiveDurationRule) Evaluate(ctx context.Context, value time.Duration) errors.ValidationError { // Exclusive: value must be < max, so reject if value >= max if value >= rule.max { - return errors.Collection( - errors.Errorf(errors.CodeMaxExclusive, ctx, "above maximum", "must be less than %s", rule.max), - ) + return errors.Errorf(errors.CodeMaxExclusive, ctx, "above maximum", "must be less than %s", rule.max) } return nil diff --git a/pkg/rules/time/rule_duration_min.go b/pkg/rules/time/rule_duration_min.go index aa966c4..f120810 100644 --- a/pkg/rules/time/rule_duration_min.go +++ b/pkg/rules/time/rule_duration_min.go @@ -15,11 +15,9 @@ type minDurationRule struct { } // Evaluate takes a context and duration value and returns an error if it is less than the specified value. -func (rule *minDurationRule) Evaluate(ctx context.Context, value time.Duration) errors.ValidationErrorCollection { +func (rule *minDurationRule) Evaluate(ctx context.Context, value time.Duration) errors.ValidationError { if value < rule.min { - return errors.Collection( - errors.Errorf(errors.CodeMin, ctx, "below minimum", "must be at least %s", rule.min), - ) + return errors.Errorf(errors.CodeMin, ctx, "below minimum", "must be at least %s", rule.min) } return nil diff --git a/pkg/rules/time/rule_duration_minexclusive.go b/pkg/rules/time/rule_duration_minexclusive.go index c5b0f33..521089d 100644 --- a/pkg/rules/time/rule_duration_minexclusive.go +++ b/pkg/rules/time/rule_duration_minexclusive.go @@ -15,12 +15,10 @@ type minExclusiveDurationRule struct { } // Evaluate takes a context and duration value and returns an error if it is not greater than the specified value (exclusive). -func (rule *minExclusiveDurationRule) Evaluate(ctx context.Context, value time.Duration) errors.ValidationErrorCollection { +func (rule *minExclusiveDurationRule) Evaluate(ctx context.Context, value time.Duration) errors.ValidationError { // Exclusive: value must be > min, so reject if value <= min if value <= rule.min { - return errors.Collection( - errors.Errorf(errors.CodeMinExclusive, ctx, "below minimum", "must be greater than %s", rule.min), - ) + return errors.Errorf(errors.CodeMinExclusive, ctx, "below minimum", "must be greater than %s", rule.min) } return nil diff --git a/pkg/rules/time/rule_duration_rounding_test.go b/pkg/rules/time/rule_duration_rounding_test.go index e9a5132..1ef16b0 100644 --- a/pkg/rules/time/rule_duration_rounding_test.go +++ b/pkg/rules/time/rule_duration_rounding_test.go @@ -139,8 +139,8 @@ func TestDurationRuleSet_WithRounding_NoRounding(t *testing.T) { err := ruleSet.Apply(context.TODO(), 5*internalTime.Second+500*internalTime.Millisecond, &output) if err == nil { t.Error("Expected error for non-evenly divisible duration") - } else if err.First().Code() != errors.CodeRange { - t.Errorf("Expected CodeRange, got %s", err.First().Code()) + } else if err.Code() != errors.CodeRange { + t.Errorf("Expected CodeRange, got %s", err.Code()) } // 5 seconds (exact) should work @@ -164,8 +164,8 @@ func TestDurationRuleSet_WithRounding_RangeError(t *testing.T) { err := ruleSet.Apply(context.TODO(), 200*internalTime.Second, &output) if err == nil { t.Error("Expected range error for value that doesn't fit in int8") - } else if err.First().Code() != errors.CodeRange { - t.Errorf("Expected CodeRange, got %s", err.First().Code()) + } else if err.Code() != errors.CodeRange { + t.Errorf("Expected CodeRange, got %s", err.Code()) } } @@ -192,8 +192,8 @@ func TestDurationRuleSet_WithRounding_InterfaceWithNumeric(t *testing.T) { err := ruleSet.Apply(context.TODO(), 5*internalTime.Second+500*internalTime.Millisecond, &output) if err == nil { t.Error("Expected error for non-evenly divisible duration with interface output") - } else if err.First().Code() != errors.CodeRange { - t.Errorf("Expected CodeRange, got %s", err.First().Code()) + } else if err.Code() != errors.CodeRange { + t.Errorf("Expected CodeRange, got %s", err.Code()) } // 5 seconds (exact) should work @@ -233,8 +233,8 @@ func TestDurationRuleSet_WithRounding_NilPointerInput(t *testing.T) { err := ruleSet.Apply(context.TODO(), input, &output) if err == nil { t.Error("Expected error for nil *time.Duration input") - } else if err.First().Code() != errors.CodeType { - t.Errorf("Expected CodeType, got %s", err.First().Code()) + } else if err.Code() != errors.CodeType { + t.Errorf("Expected CodeType, got %s", err.Code()) } } @@ -309,8 +309,8 @@ func TestDurationRuleSet_WithRounding_UnsignedOutput(t *testing.T) { err = ruleSet.Apply(context.TODO(), -5*internalTime.Second, &outputUint64) if err == nil { t.Error("Expected error for negative duration to unsigned type") - } else if err.First().Code() != errors.CodeRange { - t.Errorf("Expected CodeRange, got %s", err.First().Code()) + } else if err.Code() != errors.CodeRange { + t.Errorf("Expected CodeRange, got %s", err.Code()) } // Overflow for uint8 @@ -318,8 +318,8 @@ func TestDurationRuleSet_WithRounding_UnsignedOutput(t *testing.T) { err = ruleSet.Apply(context.TODO(), 300*internalTime.Second, &outputUint8) if err == nil { t.Error("Expected range error for overflow") - } else if err.First().Code() != errors.CodeRange { - t.Errorf("Expected CodeRange, got %s", err.First().Code()) + } else if err.Code() != errors.CodeRange { + t.Errorf("Expected CodeRange, got %s", err.Code()) } // Test interface containing unsigned int @@ -337,7 +337,7 @@ func TestDurationRuleSet_WithRounding_UnsignedOutput(t *testing.T) { err = ruleSet.Apply(context.TODO(), -5*internalTime.Second, &outputInterface) if err == nil { t.Error("Expected error for negative duration to interface containing unsigned type") - } else if err.First().Code() != errors.CodeRange { - t.Errorf("Expected CodeRange, got %s", err.First().Code()) + } else if err.Code() != errors.CodeRange { + t.Errorf("Expected CodeRange, got %s", err.Code()) } } diff --git a/pkg/rules/time/rule_max.go b/pkg/rules/time/rule_max.go index 619e390..ea9db57 100644 --- a/pkg/rules/time/rule_max.go +++ b/pkg/rules/time/rule_max.go @@ -15,11 +15,9 @@ type maxTimeRule struct { } // Evaluate takes a context and integer value and returns an error if it is not equal or lower than the specified value. -func (rule *maxTimeRule) Evaluate(ctx context.Context, value time.Time) errors.ValidationErrorCollection { +func (rule *maxTimeRule) Evaluate(ctx context.Context, value time.Time) errors.ValidationError { if value.After(rule.max) { - return errors.Collection( - errors.Errorf(errors.CodeMax, ctx, "above maximum", "must be on or before %s", rule.max), - ) + return errors.Errorf(errors.CodeMax, ctx, "above maximum", "must be on or before %s", rule.max) } return nil diff --git a/pkg/rules/time/rule_maxdiff.go b/pkg/rules/time/rule_maxdiff.go index e1d1628..042c275 100644 --- a/pkg/rules/time/rule_maxdiff.go +++ b/pkg/rules/time/rule_maxdiff.go @@ -16,11 +16,9 @@ type maxDiffRule struct { // Evaluate takes a context and integer value and returns an error if the difference between the current server time and // the time.Time value is less than than than the specified value. -func (rule *maxDiffRule) Evaluate(ctx context.Context, value time.Time) errors.ValidationErrorCollection { +func (rule *maxDiffRule) Evaluate(ctx context.Context, value time.Time) errors.ValidationError { if time.Until(value) > rule.max { - return errors.Collection( - errors.Errorf(errors.CodeMax, ctx, "above maximum", "must be on or before %s from now", rule.max), - ) + return errors.Errorf(errors.CodeMax, ctx, "above maximum", "must be on or before %s from now", rule.max) } return nil diff --git a/pkg/rules/time/rule_maxexclusive.go b/pkg/rules/time/rule_maxexclusive.go index 977112b..2193c17 100644 --- a/pkg/rules/time/rule_maxexclusive.go +++ b/pkg/rules/time/rule_maxexclusive.go @@ -15,12 +15,10 @@ type maxExclusiveTimeRule struct { } // Evaluate takes a context and time value and returns an error if it is not before the specified value (exclusive). -func (rule *maxExclusiveTimeRule) Evaluate(ctx context.Context, value time.Time) errors.ValidationErrorCollection { +func (rule *maxExclusiveTimeRule) Evaluate(ctx context.Context, value time.Time) errors.ValidationError { // Exclusive: value must be < max, so reject if value >= max if !value.Before(rule.max) { - return errors.Collection( - errors.Errorf(errors.CodeMaxExclusive, ctx, "above maximum", "must be before %s", rule.max), - ) + return errors.Errorf(errors.CodeMaxExclusive, ctx, "above maximum", "must be before %s", rule.max) } return nil diff --git a/pkg/rules/time/rule_min.go b/pkg/rules/time/rule_min.go index d491efd..946e118 100644 --- a/pkg/rules/time/rule_min.go +++ b/pkg/rules/time/rule_min.go @@ -15,11 +15,9 @@ type minTimeRule struct { } // Evaluate takes a context and integer value and returns an error if it is not equal or later than than the specified value. -func (rule *minTimeRule) Evaluate(ctx context.Context, value time.Time) errors.ValidationErrorCollection { +func (rule *minTimeRule) Evaluate(ctx context.Context, value time.Time) errors.ValidationError { if value.Before(rule.min) { - return errors.Collection( - errors.Errorf(errors.CodeMin, ctx, "below minimum", "must be on or after %s", rule.min), - ) + return errors.Errorf(errors.CodeMin, ctx, "below minimum", "must be on or after %s", rule.min) } return nil diff --git a/pkg/rules/time/rule_mindiff.go b/pkg/rules/time/rule_mindiff.go index c1ef0a0..de529b7 100644 --- a/pkg/rules/time/rule_mindiff.go +++ b/pkg/rules/time/rule_mindiff.go @@ -16,11 +16,9 @@ type minDiffRule struct { // Evaluate takes a context and integer value and returns an error if the difference between the current server time and // the time.Time value is less than than than the specified value. -func (rule *minDiffRule) Evaluate(ctx context.Context, value time.Time) errors.ValidationErrorCollection { +func (rule *minDiffRule) Evaluate(ctx context.Context, value time.Time) errors.ValidationError { if time.Until(value) < rule.min { - return errors.Collection( - errors.Errorf(errors.CodeMin, ctx, "below minimum", "must be on or after %s from now", rule.min), - ) + return errors.Errorf(errors.CodeMin, ctx, "below minimum", "must be on or after %s from now", rule.min) } return nil diff --git a/pkg/rules/time/rule_minexclusive.go b/pkg/rules/time/rule_minexclusive.go index c1c02e5..f585f30 100644 --- a/pkg/rules/time/rule_minexclusive.go +++ b/pkg/rules/time/rule_minexclusive.go @@ -15,12 +15,10 @@ type minExclusiveTimeRule struct { } // Evaluate takes a context and time value and returns an error if it is not after the specified value (exclusive). -func (rule *minExclusiveTimeRule) Evaluate(ctx context.Context, value time.Time) errors.ValidationErrorCollection { +func (rule *minExclusiveTimeRule) Evaluate(ctx context.Context, value time.Time) errors.ValidationError { // Exclusive: value must be > min, so reject if value <= min if !value.After(rule.min) { - return errors.Collection( - errors.Errorf(errors.CodeMinExclusive, ctx, "below minimum", "must be after %s", rule.min), - ) + return errors.Errorf(errors.CodeMinExclusive, ctx, "below minimum", "must be after %s", rule.min) } return nil diff --git a/pkg/rules/time/time.go b/pkg/rules/time/time.go index 19a1998..db80cf3 100644 --- a/pkg/rules/time/time.go +++ b/pkg/rules/time/time.go @@ -168,8 +168,8 @@ func (ruleSet *TimeRuleSet) WithOutputLayout(layout string) *TimeRuleSet { } // Apply performs validation of a RuleSet against a value and assigns the result to the output parameter. -// Apply returns a ValidationErrorCollection if any validation errors occur. -func (ruleSet *TimeRuleSet) Apply(ctx context.Context, input any, output any) errors.ValidationErrorCollection { +// Apply returns a ValidationError if any validation errors occur. +func (ruleSet *TimeRuleSet) Apply(ctx context.Context, input any, output any) errors.ValidationError { // Add error config to context for error customization ctx = errors.WithErrorConfig(ctx, ruleSet.errorConfig) @@ -181,9 +181,7 @@ func (ruleSet *TimeRuleSet) Apply(ctx context.Context, input any, output any) er // Ensure output is a non-nil pointer outputVal := reflect.ValueOf(output) if outputVal.Kind() != reflect.Ptr || outputVal.IsNil() { - return errors.Collection(errors.Errorf( - errors.CodeInternal, ctx, "internal error", "Output must be a non-nil pointer", - )) + return errors.Errorf(errors.CodeInternal, ctx, "internal error", "Output must be a non-nil pointer") } var t time.Time @@ -220,10 +218,10 @@ func (ruleSet *TimeRuleSet) Apply(ctx context.Context, input any, output any) er } } if !ok { - return errors.Collection(errors.Error(errors.CodeType, ctx, "date time", "string")) + return errors.Error(errors.CodeType, ctx, "date time", "string") } default: - return errors.Collection(errors.Error(errors.CodeType, ctx, "date time", reflect.TypeOf(input).String())) + return errors.Error(errors.CodeType, ctx, "date time", reflect.TypeOf(input).String()) } // Overwrite layout if outputLayout is set @@ -244,37 +242,27 @@ func (ruleSet *TimeRuleSet) Apply(ctx context.Context, input any, output any) er formattedTime := t.Format(layout) outputElem.Set(reflect.ValueOf(formattedTime)) } else { - return errors.Collection(errors.Errorf( - errors.CodeInternal, ctx, "internal error", "Cannot assign %T to %T", t, outputElem.Interface(), - )) + return errors.Errorf(errors.CodeInternal, ctx, "internal error", "Cannot assign %T to %T", t, outputElem.Interface()) } // Evaluate the time value and return any validation errors return ruleSet.Evaluate(ctx, t) } -// Evaluate performs validation of a RuleSet against a time.Time value and returns a ValidationErrorCollection. -func (ruleSet *TimeRuleSet) Evaluate(ctx context.Context, value time.Time) errors.ValidationErrorCollection { - allErrors := errors.Collection() - +// Evaluate performs validation of a RuleSet against a time.Time value and returns a ValidationError. +func (ruleSet *TimeRuleSet) Evaluate(ctx context.Context, value time.Time) errors.ValidationError { + var errs errors.ValidationError currentRuleSet := ruleSet ctx = rulecontext.WithRuleSet(ctx, ruleSet) - for currentRuleSet != nil { if currentRuleSet.rule != nil { - if errs := currentRuleSet.rule.Evaluate(ctx, value); errs != nil { - allErrors = append(allErrors, errs...) + if e := currentRuleSet.rule.Evaluate(ctx, value); e != nil { + errs = errors.Join(errs, e) } } - currentRuleSet = currentRuleSet.parent } - - if len(allErrors) > 0 { - return allErrors - } else { - return nil - } + return errs } // noConflict returns the new array rule set with all conflicting rules removed. diff --git a/pkg/rules/time/time_private_test.go b/pkg/rules/time/time_private_test.go index b68b559..35ec5d3 100644 --- a/pkg/rules/time/time_private_test.go +++ b/pkg/rules/time/time_private_test.go @@ -38,7 +38,7 @@ func TestTimeConflictType_Replaces_WrongType(t *testing.T) { // Test with a regular rule (not a ruleset) - cast should fail // RuleFunc doesn't implement the TimeRuleSet interface, so the cast in Replaces should fail - rule := rules.RuleFunc[time.Time](func(ctx context.Context, value time.Time) errors.ValidationErrorCollection { + rule := rules.RuleFunc[time.Time](func(ctx context.Context, value time.Time) errors.ValidationError { return nil }) if checker.Replaces(rule) { diff --git a/pkg/rules/wrap_any.go b/pkg/rules/wrap_any.go index 485a984..179d0a4 100644 --- a/pkg/rules/wrap_any.go +++ b/pkg/rules/wrap_any.go @@ -89,29 +89,25 @@ func (v *WrapAnyRuleSet[T]) WithNil() *WrapAnyRuleSet[T] { // evaluateRules runs all the rules and returns any errors. // Returns a collection regardless of if there are any errors. -func (v *WrapAnyRuleSet[T]) evaluateRules(ctx context.Context, value any) errors.ValidationErrorCollection { - allErrors := errors.Collection() - +func (v *WrapAnyRuleSet[T]) evaluateRules(ctx context.Context, value any) errors.ValidationError { + var errs errors.ValidationError currentRuleSet := v ctx = rulecontext.WithRuleSet(ctx, v) - for currentRuleSet != nil { if currentRuleSet.rule != nil { - if errs := currentRuleSet.rule.Evaluate(ctx, value); errs != nil { - allErrors = append(allErrors, errs...) + if e := currentRuleSet.rule.Evaluate(ctx, value); e != nil { + errs = errors.Join(errs, e) } } - currentRuleSet = currentRuleSet.parent } - - return allErrors + return errs } // Apply performs validation of a RuleSet against a value and assigns the result to the output parameter. // Apply calls wrapped rules before any rules added directly to the WrapAnyRuleSet. -// Apply returns a ValidationErrorCollection if any validation errors occur. -func (v *WrapAnyRuleSet[T]) Apply(ctx context.Context, input, output any) errors.ValidationErrorCollection { +// Apply returns a ValidationError if any validation errors occur. +func (v *WrapAnyRuleSet[T]) Apply(ctx context.Context, input, output any) errors.ValidationError { // Add error config to context for error customization ctx = errors.WithErrorConfig(ctx, v.errorConfig) @@ -121,36 +117,15 @@ func (v *WrapAnyRuleSet[T]) Apply(ctx context.Context, input, output any) errors } innerErrors := v.inner.Apply(ctx, input, output) - allErrors := v.evaluateRules(ctx, output) - - if innerErrors != nil { - allErrors = append(allErrors, innerErrors...) - } - - if len(allErrors) > 0 { - return allErrors - } else { - return nil - } + return errors.Join(v.evaluateRules(ctx, output), innerErrors) } -// Evaluate performs validation of a RuleSet against a value of any type and returns a ValidationErrorCollection. +// Evaluate performs validation of a RuleSet against a value of any type and returns a ValidationError. // Evaluate calls the wrapped RuleSet's Evaluate method directly if the input value implements the same type, // otherwise it calls Apply. This approach is usually more efficient since it does not need to allocate an output variable. -func (ruleSet *WrapAnyRuleSet[T]) Evaluate(ctx context.Context, value any) errors.ValidationErrorCollection { +func (ruleSet *WrapAnyRuleSet[T]) Evaluate(ctx context.Context, value any) errors.ValidationError { if v, ok := value.(T); ok { - innerErrors := ruleSet.inner.Evaluate(ctx, v) - allErrors := ruleSet.evaluateRules(ctx, value) - - if innerErrors != nil { - allErrors = append(allErrors, innerErrors...) - } - - if len(allErrors) != 0 { - return allErrors - } else { - return nil - } + return errors.Join(ruleSet.evaluateRules(ctx, value), ruleSet.inner.Evaluate(ctx, v)) } else { var out T errs := ruleSet.Apply(ctx, value, &out) diff --git a/pkg/testhelpers/mock.go b/pkg/testhelpers/mock.go index 5fdb08c..393716d 100644 --- a/pkg/testhelpers/mock.go +++ b/pkg/testhelpers/mock.go @@ -23,7 +23,7 @@ type MockRule[T any] struct { evaluateCallCount int64 // fn stores the function representation of the rule - fn func(_ context.Context, _ T) errors.ValidationErrorCollection + fn func(_ context.Context, _ T) errors.ValidationError // Errors is used to return errors to the mock caller. Errors []errors.ValidationError @@ -46,11 +46,12 @@ func NewMockRuleWithErrors[T any](count int) *MockRule[T] { } // defaultErrors returns a collection of the default errors or nil depending on how the mock is configured -func (rule *MockRule[T]) defaultErrors() errors.ValidationErrorCollection { - if len(rule.Errors) > 0 { - return errors.Collection(rule.Errors...) +func (rule *MockRule[T]) defaultErrors() errors.ValidationError { + errSlice := make([]error, len(rule.Errors)) + for i, e := range rule.Errors { + errSlice[i] = e } - return nil + return errors.Join(errSlice...) } // Evaluate takes a context and a value to evaluate. @@ -58,7 +59,7 @@ func (rule *MockRule[T]) defaultErrors() errors.ValidationErrorCollection { // - If errors are set then it will return all the errors. // - If an override return value is set it will return that. // - If neither, it will return the original value and no errors. -func (rule *MockRule[T]) Evaluate(ctx context.Context, value T) errors.ValidationErrorCollection { +func (rule *MockRule[T]) Evaluate(ctx context.Context, value T) errors.ValidationError { atomic.AddInt64(&rule.evaluateCallCount, 1) return rule.defaultErrors() } @@ -93,9 +94,9 @@ func (rule *MockRule[T]) Reset() { // store a copy of the MockCustomRule if you wish to retrieve the count. // // Calling this function more than once will result in the same function being returned. -func (rule *MockRule[T]) Function() func(_ context.Context, _ T) errors.ValidationErrorCollection { +func (rule *MockRule[T]) Function() func(_ context.Context, _ T) errors.ValidationError { if rule.fn == nil { - rule.fn = func(ctx context.Context, value T) errors.ValidationErrorCollection { + rule.fn = func(ctx context.Context, value T) errors.ValidationError { return rule.Evaluate(ctx, value) } } @@ -177,7 +178,7 @@ func (mockRuleSet *MockRuleSet[T]) ApplyCallCount() int64 { // Apply tries to do a simple cast and returns an error if it fails. It then calls // Evaluate. Cast errors do not count towards the run count. -func (mockRuleSet *MockRuleSet[T]) Apply(ctx context.Context, input, output any) errors.ValidationErrorCollection { +func (mockRuleSet *MockRuleSet[T]) Apply(ctx context.Context, input, output any) errors.ValidationError { atomic.AddInt64(&mockRuleSet.applyCallCount, 1) // Add error config to context for error customization @@ -190,14 +191,13 @@ func (mockRuleSet *MockRuleSet[T]) Apply(ctx context.Context, input, output any) // Check if the output is a nil pointer, handle error case if output == nil { - return errors.Collection(errors.Errorf(errors.CodeInternal, ctx, "internal error", "output cannot be nil")) + return errors.Errorf(errors.CodeInternal, ctx, "internal error", "output cannot be nil") } outputVal := reflect.ValueOf(output) - // Check if output is a pointer if outputVal.Kind() != reflect.Ptr || outputVal.IsNil() { - return errors.Collection(errors.Errorf(errors.CodeInternal, ctx, "internal error", "output must be a non-nil pointer, got %T", output)) + return errors.Errorf(errors.CodeInternal, ctx, "internal error", "output must be a non-nil pointer, got %T", output) } outputElem := outputVal.Elem() @@ -208,7 +208,7 @@ func (mockRuleSet *MockRuleSet[T]) Apply(ctx context.Context, input, output any) // Ensure the mockRuleSet.OutputValue is assignable to the output's pointed type if !mockValue.Type().AssignableTo(outputElem.Type()) { - return errors.Collection(errors.Errorf(errors.CodeInternal, ctx, "internal error", "cannot assign %T to %T", mockRuleSet.OutputValue, output)) + return errors.Errorf(errors.CodeInternal, ctx, "internal error", "cannot assign %T to %T", mockRuleSet.OutputValue, output) } // Set the mockRuleSet.OutputValue to the output @@ -220,7 +220,7 @@ func (mockRuleSet *MockRuleSet[T]) Apply(ctx context.Context, input, output any) inputVal := reflect.ValueOf(input) if !inputVal.Type().AssignableTo(outputElem.Type()) { - return errors.Collection(errors.Errorf(errors.CodeInternal, ctx, "internal error", "cannot assign %T to %T", input, output)) + return errors.Errorf(errors.CodeInternal, ctx, "internal error", "cannot assign %T to %T", input, output) } // Set the input value to output diff --git a/pkg/testhelpers/mock_test.go b/pkg/testhelpers/mock_test.go index e98cd75..b2bab03 100644 --- a/pkg/testhelpers/mock_test.go +++ b/pkg/testhelpers/mock_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "proto.zip/studio/validate/pkg/errors" "proto.zip/studio/validate/pkg/rules" "proto.zip/studio/validate/pkg/testhelpers" ) @@ -25,7 +26,7 @@ func TestMockRule(t *testing.T) { err = rule2(ctx, 456) if err == nil { t.Error("Expected error to not be nil") - } else if s := len(err); s != 1 { + } else if s := len(errors.Unwrap(err)); s != 1 { t.Errorf("Expected error collection size to be %d, got: %d", 1, s) } @@ -34,7 +35,7 @@ func TestMockRule(t *testing.T) { err = rule3(ctx, 456) if err == nil { t.Error("Expected error to not be nil") - } else if s := len(err); s != 2 { + } else if s := len(errors.Unwrap(err)); s != 2 { t.Errorf("Expected error collection size to be %d, got: %d", 2, s) } } diff --git a/pkg/testhelpers/util.go b/pkg/testhelpers/util.go index ce8d605..eb7eac2 100644 --- a/pkg/testhelpers/util.go +++ b/pkg/testhelpers/util.go @@ -56,9 +56,12 @@ func MustApplyFunc(t testing.TB, ruleSet rules.RuleSet[any], input, expectedOutp if err != nil { str := "Expected error to be nil" - - for _, inner := range err { - str += fmt.Sprintf("\n %s at %s", inner, inner.Path()) + for _, inner := range errors.Unwrap(err) { + if ve, ok := inner.(errors.ValidationError); ok { + str += fmt.Sprintf("\n %s at %s", ve, ve.Path()) + } else { + str += fmt.Sprintf("\n %s", inner) + } } t.Error(str) @@ -104,8 +107,8 @@ func MustNotApply(t testing.TB, ruleSet rules.RuleSet[any], input any, errorCode if err == nil { t.Error("Expected error to not be nil") return nil - } else if err.First().Code() != errorCode { - t.Errorf("Expected error code of %s, got %s (%s)", errorCode, err.First().Code(), err) + } else if err.Code() != errorCode { + t.Errorf("Expected error code of %s, got %s (%s)", errorCode, err.Code(), err) return nil } @@ -148,7 +151,7 @@ func MustApplyTypes[T any](t testing.TB, ruleSet rules.RuleSet[T], input T) { err = ruleSet.Apply(context.TODO(), input, outputNonPointer) if err == nil { t.Errorf("Expected error to not be nil on `%T` output", outputNonPointer) - } else if code := err.First().Code(); code != errors.CodeInternal { + } else if code := err.Code(); code != errors.CodeInternal { t.Errorf("Expected error code to be %s (errors.CodeInternal) on `%T` output, got: %s", errors.CodeInternal, outputNonPointer, code) } @@ -157,18 +160,17 @@ func MustApplyTypes[T any](t testing.TB, ruleSet rules.RuleSet[T], input T) { err = ruleSet.Apply(context.TODO(), input, outputPointerToNil) if err == nil { t.Error("Expected error to not be nil on pointer to `nil` output") - } else if code := err.First().Code(); code != errors.CodeInternal { + } else if code := err.Code(); code != errors.CodeInternal { t.Errorf("Expected error code to be %s (errors.CodeInternal) on pointer to `nil` output, got: %s", errors.CodeInternal, code) } // Incompatible type - // We must assign a &neverAssignableImpl{} to avoid false errors because the pointer was nil var outputIncompatible neverAssignable = &neverAssignableImpl{privProp: 1} outputIncompatible.priv() err = ruleSet.Apply(context.TODO(), input, outputIncompatible) if err == nil { t.Error("Expected error to not be nil on incompatible output") - } else if code := err.First().Code(); code != errors.CodeInternal { + } else if code := err.Code(); code != errors.CodeInternal { t.Errorf("Expected error code to be %s (errors.CodeInternal) on incompatible output, got: %s", errors.CodeInternal, code) } @@ -176,7 +178,7 @@ func MustApplyTypes[T any](t testing.TB, ruleSet rules.RuleSet[T], input T) { err = ruleSet.Apply(context.TODO(), input, nil) if err == nil { t.Error("Expected error to not be nil on `nil` output") - } else if code := err.First().Code(); code != errors.CodeInternal { + } else if code := err.Code(); code != errors.CodeInternal { t.Errorf("Expected error code to be %s (errors.CodeInternal) on `nil` output, got: %s", errors.CodeInternal, code) } } @@ -193,9 +195,12 @@ func MustEvaluate[T any](t testing.TB, rule rules.Rule[T], input T) error { if err != nil { str := "Expected error to be nil" - - for _, inner := range err { - str += fmt.Sprintf("\n %s at %s", inner, inner.Path()) + for _, inner := range errors.Unwrap(err) { + if ve, ok := inner.(errors.ValidationError); ok { + str += fmt.Sprintf("\n %s at %s", ve, ve.Path()) + } else { + str += fmt.Sprintf("\n %s", inner) + } } t.Error(str) @@ -219,8 +224,8 @@ func MustNotEvaluate[T any](t testing.TB, rule rules.Rule[T], input T, errorCode if err == nil { t.Error("Expected error to not be nil") return nil - } else if err.First().Code() != errorCode { - t.Errorf("Expected error code of %s, got %s (%s)", errorCode, err.First().Code(), err) + } else if err.Code() != errorCode { + t.Errorf("Expected error code of %s, got %s (%s)", errorCode, err.Code(), err) return nil } diff --git a/pkg/testhelpers/util_test.go b/pkg/testhelpers/util_test.go index ac3ace5..6fa5296 100644 --- a/pkg/testhelpers/util_test.go +++ b/pkg/testhelpers/util_test.go @@ -53,8 +53,19 @@ func TestMustApply(t *testing.T) { } } +// ruleSetWithPlainUnwrapError is a RuleSet whose Apply returns an error whose Unwrap() contains a plain error. +// Used to cover the else branch in MustApplyFunc's error-formatting loop. +type ruleSetWithPlainUnwrapError struct { + testhelpers.MockRuleSet[any] +} + +func (r *ruleSetWithPlainUnwrapError) Apply(_ context.Context, _ any, _ any) errors.ValidationError { + return &errorWithPlainUnwrap{msg: "apply err"} +} + // TestMustApplyFunc tests: // - MustApplyFunc correctly validates rule sets with custom check function +// - MustApplyFunc formats unwrapped errors that are not ValidationError (else branch) func TestMustApplyFunc(t *testing.T) { ruleSet := rules.Any() callCount := 0 @@ -92,6 +103,18 @@ func TestMustApplyFunc(t *testing.T) { if callCount != 1 { t.Errorf("Expected check function call count to be 1, got: %d", callCount) } + + // RuleSet that returns an error whose Unwrap() contains a plain error (covers else branch in MustApplyFunc) + ruleSetPlainUnwrap := &ruleSetWithPlainUnwrapError{ + MockRuleSet: *testhelpers.NewMockRuleSet[any](), + } + mockT = &MockT{} + if _, err := testhelpers.MustApplyFunc(mockT, ruleSetPlainUnwrap, 10, 10, checkValid); err == nil { + t.Error("Expected error to not be nil") + } + if mockT.errorCount != 1 { + t.Errorf("Expected error count to be 1, got: %d", mockT.errorCount) + } } // TestMustNotApply tests: @@ -155,7 +178,7 @@ func TestMustApplyMutation(t *testing.T) { // MockNilOk is a mock rule set that incorrectly succeeds when applying nil type MockNilOk struct{ testhelpers.MockRuleSet[int] } -func (m *MockNilOk) Apply(ctx context.Context, input, output any) errors.ValidationErrorCollection { +func (m *MockNilOk) Apply(ctx context.Context, input, output any) errors.ValidationError { if output == nil { return nil } @@ -166,7 +189,7 @@ func (m *MockNilOk) Apply(ctx context.Context, input, output any) errors.Validat // MockNilOk is a mock rule set that incorrectly succeeds when applying a pointer to nil type MockNilPtrOk struct{ testhelpers.MockRuleSet[int] } -func (m *MockNilPtrOk) Apply(ctx context.Context, input, output any) errors.ValidationErrorCollection { +func (m *MockNilPtrOk) Apply(ctx context.Context, input, output any) errors.ValidationError { if outputPtr, ok := output.(*int); ok && outputPtr == nil { return nil } @@ -177,7 +200,7 @@ func (m *MockNilPtrOk) Apply(ctx context.Context, input, output any) errors.Vali // MockNilOk is a mock rule set that incorrectly succeeds when applying a non-pointer that matches the type type MockNonPtrOk struct{ testhelpers.MockRuleSet[int] } -func (m *MockNonPtrOk) Apply(ctx context.Context, input, output any) errors.ValidationErrorCollection { +func (m *MockNonPtrOk) Apply(ctx context.Context, input, output any) errors.ValidationError { if _, ok := output.(int); ok { return nil } @@ -188,7 +211,7 @@ func (m *MockNonPtrOk) Apply(ctx context.Context, input, output any) errors.Vali // MockWrongTypeOk is a mock rule set that incorrectly succeeds when applying a pointer with the wrong type type MockWrongTypeOk struct{ testhelpers.MockRuleSet[int] } -func (m *MockWrongTypeOk) Apply(ctx context.Context, input, output any) errors.ValidationErrorCollection { +func (m *MockWrongTypeOk) Apply(ctx context.Context, input, output any) errors.ValidationError { // Always succeed on non-nil pointer regardless of type outputVal := reflect.ValueOf(output) if outputVal.Kind() == reflect.Ptr && !outputVal.IsNil() { @@ -202,28 +225,34 @@ func (m *MockWrongTypeOk) Apply(ctx context.Context, input, output any) errors.V // errors.CodeInternal should be used and is replaced with errors.CodeUknown type MockWrongErrorCode struct{ testhelpers.MockRuleSet[int] } -func (m *MockWrongErrorCode) Apply(ctx context.Context, input, output any) errors.ValidationErrorCollection { +func (m *MockWrongErrorCode) Apply(ctx context.Context, input, output any) errors.ValidationError { mockRuleSet := &testhelpers.MockRuleSet[int]{} errs := mockRuleSet.Apply(ctx, input, output) if errs == nil { return nil } - - // Replace all CodeInternal errors - for idx := range errs { - if errs[idx].Code() == errors.CodeInternal { - errs[idx] = errors.Errorf(errors.CodeUnknown, ctx, "unknown error", "") + coll := errors.Unwrap(errs) + var out []error + for _, e := range coll { + ve, ok := e.(errors.ValidationError) + if !ok { + out = append(out, e) + continue + } + if ve.Code() == errors.CodeInternal { + out = append(out, errors.Errorf(errors.CodeUnknown, ctx, "unknown error", "")) + } else { + out = append(out, ve) } } - - return errs + return errors.Join(out...) } // MockAlwaysError is a mock rule set that always fails type MockAlwaysError struct{ testhelpers.MockRuleSet[int] } -func (m *MockAlwaysError) Apply(ctx context.Context, input, output any) errors.ValidationErrorCollection { +func (m *MockAlwaysError) Apply(ctx context.Context, input, output any) errors.ValidationError { mockRuleSet := &testhelpers.MockRuleSet[int]{} errs := mockRuleSet.Apply(ctx, input, output) @@ -231,7 +260,7 @@ func (m *MockAlwaysError) Apply(ctx context.Context, input, output any) errors.V return errs } - return errors.Collection(errors.Errorf(errors.CodeUnknown, ctx, "unknown error", "")) + return errors.Join(errors.Errorf(errors.CodeUnknown, ctx, "unknown error", "")) } // TestMustApplyTypes tests: @@ -291,8 +320,29 @@ func TestMustApplyTypes(t *testing.T) { } } +// errorWithPlainUnwrap is a ValidationError whose Unwrap() returns a plain (non-ValidationError) error. +// Used to cover the branch in MustEvaluate/MustApplyFunc that formats non-VE unwrapped errors. +type errorWithPlainUnwrap struct { + msg string +} + +func (e *errorWithPlainUnwrap) Error() string { return e.msg } +func (e *errorWithPlainUnwrap) Unwrap() []error { return []error{fmt.Errorf("inner plain error")} } +func (e *errorWithPlainUnwrap) Code() errors.ErrorCode { return errors.CodeUnknown } +func (e *errorWithPlainUnwrap) Path() string { return "" } +func (e *errorWithPlainUnwrap) PathAs(_ errors.PathSerializer) string { return "" } +func (e *errorWithPlainUnwrap) ShortError() string { return "short" } +func (e *errorWithPlainUnwrap) DocsURI() string { return "" } +func (e *errorWithPlainUnwrap) TraceURI() string { return "" } +func (e *errorWithPlainUnwrap) Meta() map[string]any { return nil } +func (e *errorWithPlainUnwrap) Params() []any { return nil } +func (e *errorWithPlainUnwrap) Internal() bool { return false } +func (e *errorWithPlainUnwrap) Validation() bool { return true } +func (e *errorWithPlainUnwrap) Permission() bool { return false } + // TestMustEvaluate tests: // - MustEvaluate correctly validates rules +// - MustEvaluate formats unwrapped errors that are not ValidationError (else branch) func TestMustEvaluate(t *testing.T) { rule := testhelpers.NewMockRuleWithErrors[any](1) @@ -312,6 +362,18 @@ func TestMustEvaluate(t *testing.T) { if mockT.errorCount != 0 { t.Errorf("Expected error count to be 0, got: %d", mockT.errorCount) } + + // Rule that returns an error whose Unwrap() contains a plain error (covers else branch in MustEvaluate) + ruleWithPlainUnwrap := rules.RuleFunc[any](func(_ context.Context, _ any) errors.ValidationError { + return &errorWithPlainUnwrap{msg: "wrapper"} + }) + mockT = &MockT{} + if err := testhelpers.MustEvaluate[any](mockT, ruleWithPlainUnwrap, 10); err == nil { + t.Error("Expected error to not be nil") + } + if mockT.errorCount != 1 { + t.Errorf("Expected error count to be 1, got: %d", mockT.errorCount) + } } // TestMustNotEvaluate tests: diff --git a/pkg/testhelpers/witherrorconfig.go b/pkg/testhelpers/witherrorconfig.go index ae26101..1557d18 100644 --- a/pkg/testhelpers/witherrorconfig.go +++ b/pkg/testhelpers/witherrorconfig.go @@ -12,8 +12,8 @@ import ( // This can be used with WithRule to test error config propagation. type ErrorConfigTestRule[T any] struct{} -func (r *ErrorConfigTestRule[T]) Evaluate(ctx context.Context, value T) errors.ValidationErrorCollection { - return errors.Collection(errors.Error(errors.CodePattern, ctx)) +func (r *ErrorConfigTestRule[T]) Evaluate(ctx context.Context, value T) errors.ValidationError { + return errors.Error(errors.CodePattern, ctx) } func (r *ErrorConfigTestRule[T]) Replaces(other rules.Rule[T]) bool { @@ -28,8 +28,8 @@ func (r *ErrorConfigTestRule[T]) String() string { // ErrorConfigTestRuleFunc returns a rule function that creates errors via errors.Error. // This can be used with WithRuleFunc to test error config propagation. func ErrorConfigTestRuleFunc[T any]() rules.RuleFunc[T] { - return func(ctx context.Context, value T) errors.ValidationErrorCollection { - return errors.Collection(errors.Error(errors.CodePattern, ctx)) + return func(ctx context.Context, value T) errors.ValidationError { + return errors.Error(errors.CodePattern, ctx) } } @@ -69,14 +69,14 @@ func mustApplyErrorConfigWithMessage[T any, RS ruleSetWithErrorConfig[T, RS]](t // Trigger an error by passing nil without WithNil var output T - errs := ruleSetWithConfig.Apply(context.Background(), nil, &output) + errs := errors.Unwrap(ruleSetWithConfig.Apply(context.Background(), nil, &output)) if len(errs) == 0 { t.Error("WithErrorMessage: Expected validation error for nil input") return } - - if errs[0].ShortError() != "custom short" { - t.Errorf("WithErrorMessage: Expected short error 'custom short', got: %s", errs[0].ShortError()) + ve := errs[0].(errors.ValidationError) + if ve.ShortError() != "custom short" { + t.Errorf("WithErrorMessage: Expected short error 'custom short', got: %s", ve.ShortError()) } } @@ -88,14 +88,14 @@ func mustApplyErrorConfigWithDocsURI[T any, RS ruleSetWithErrorConfig[T, RS]](t // Trigger an error by passing nil without WithNil var output T - errs := ruleSetWithConfig.Apply(context.Background(), nil, &output) + errs := errors.Unwrap(ruleSetWithConfig.Apply(context.Background(), nil, &output)) if len(errs) == 0 { t.Error("WithDocsURI: Expected validation error for nil input") return } - - if errs[0].DocsURI() != "https://example.com/docs" { - t.Errorf("WithDocsURI: Expected DocsURI 'https://example.com/docs', got: %s", errs[0].DocsURI()) + ve := errs[0].(errors.ValidationError) + if ve.DocsURI() != "https://example.com/docs" { + t.Errorf("WithDocsURI: Expected DocsURI 'https://example.com/docs', got: %s", ve.DocsURI()) } } @@ -107,14 +107,14 @@ func mustApplyErrorConfigWithTraceURI[T any, RS ruleSetWithErrorConfig[T, RS]](t // Trigger an error by passing nil without WithNil var output T - errs := ruleSetWithConfig.Apply(context.Background(), nil, &output) + errs := errors.Unwrap(ruleSetWithConfig.Apply(context.Background(), nil, &output)) if len(errs) == 0 { t.Error("WithTraceURI: Expected validation error for nil input") return } - - if errs[0].TraceURI() != "https://example.com/trace/123" { - t.Errorf("WithTraceURI: Expected TraceURI 'https://example.com/trace/123', got: %s", errs[0].TraceURI()) + ve := errs[0].(errors.ValidationError) + if ve.TraceURI() != "https://example.com/trace/123" { + t.Errorf("WithTraceURI: Expected TraceURI 'https://example.com/trace/123', got: %s", ve.TraceURI()) } } @@ -126,14 +126,14 @@ func mustApplyErrorConfigWithCode[T any, RS ruleSetWithErrorConfig[T, RS]](t tes // Trigger an error by passing nil without WithNil var output T - errs := ruleSetWithConfig.Apply(context.Background(), nil, &output) + errs := errors.Unwrap(ruleSetWithConfig.Apply(context.Background(), nil, &output)) if len(errs) == 0 { t.Error("WithErrorCode: Expected validation error for nil input") return } - - if errs[0].Code() != errors.CodeForbidden { - t.Errorf("WithErrorCode: Expected code %s, got: %s", errors.CodeForbidden, errs[0].Code()) + ve := errs[0].(errors.ValidationError) + if ve.Code() != errors.CodeForbidden { + t.Errorf("WithErrorCode: Expected code %s, got: %s", errors.CodeForbidden, ve.Code()) } } @@ -145,13 +145,13 @@ func mustApplyErrorConfigWithMeta[T any, RS ruleSetWithErrorConfig[T, RS]](t tes // Trigger an error by passing nil without WithNil var output T - errs := ruleSetWithConfig.Apply(context.Background(), nil, &output) + errs := errors.Unwrap(ruleSetWithConfig.Apply(context.Background(), nil, &output)) if len(errs) == 0 { t.Error("WithErrorMeta: Expected validation error for nil input") return } - - meta := errs[0].Meta() + ve := errs[0].(errors.ValidationError) + meta := ve.Meta() if meta == nil || meta["field"] != "testvalue" { t.Errorf("WithErrorMeta: Expected meta['field'] to be 'testvalue', got: %v", meta) } @@ -174,7 +174,7 @@ func mustApplyErrorConfigWithCallback[T any, RS ruleSetWithErrorConfig[T, RS]](t // Trigger an error by passing nil without WithNil var output T - errs := ruleSetWithConfig.Apply(context.Background(), nil, &output) + errs := errors.Unwrap(ruleSetWithConfig.Apply(context.Background(), nil, &output)) if len(errs) == 0 { t.Error("WithErrorCallback: Expected validation error for nil input") return @@ -208,14 +208,14 @@ func MustApplyErrorConfigWithCustomRule[T any](t testing.TB, ruleSet rules.RuleS t.Helper() var output T - errs := ruleSet.Apply(context.Background(), triggerInput, &output) + errs := errors.Unwrap(ruleSet.Apply(context.Background(), triggerInput, &output)) if len(errs) == 0 { t.Error("Expected validation error from custom rule") return } - - if errs[0].DocsURI() != expectedDocsURI { - t.Errorf("Expected DocsURI '%s', got: %s", expectedDocsURI, errs[0].DocsURI()) + ve := errs[0].(errors.ValidationError) + if ve.DocsURI() != expectedDocsURI { + t.Errorf("Expected DocsURI '%s', got: %s", expectedDocsURI, ve.DocsURI()) } } @@ -225,13 +225,13 @@ func MustApplyErrorConfigWithMetaOnInput[T any](t testing.TB, ruleSet rules.Rule t.Helper() var output T - errs := ruleSet.Apply(context.Background(), triggerInput, &output) + errs := errors.Unwrap(ruleSet.Apply(context.Background(), triggerInput, &output)) if len(errs) == 0 { t.Error("Expected validation error") return } - - meta := errs[0].Meta() + ve := errs[0].(errors.ValidationError) + meta := ve.Meta() if meta == nil || meta[expectedKey] != expectedValue { t.Errorf("Expected meta['%s'] to be '%v', got: %v", expectedKey, expectedValue, meta) } diff --git a/pkg/testhelpers/witherrorconfig_test.go b/pkg/testhelpers/witherrorconfig_test.go index c891eb9..ef114f4 100644 --- a/pkg/testhelpers/witherrorconfig_test.go +++ b/pkg/testhelpers/witherrorconfig_test.go @@ -49,7 +49,7 @@ func TestErrorConfigTestRuleFunc(t *testing.T) { // The function should return errors (since it's for testing error config) errs := fn(context.Background(), "test") - if len(errs) == 0 { + if len(errors.Unwrap(errs)) == 0 { t.Error("Expected ErrorConfigTestRuleFunc to produce errors") } } @@ -299,6 +299,7 @@ func (e *brokenValidationError) Params() []any { return nil } func (e *brokenValidationError) Internal() bool { return false } func (e *brokenValidationError) Validation() bool { return true } func (e *brokenValidationError) Permission() bool { return false } +func (e *brokenValidationError) Unwrap() []error { return nil } // MockCallbackBrokenError produces errors with empty fields that the callback will capture type MockCallbackBrokenError struct { @@ -307,13 +308,12 @@ type MockCallbackBrokenError struct { callback errors.ErrorCallback } -func (m *MockCallbackBrokenError) Apply(ctx context.Context, input, output any) errors.ValidationErrorCollection { +func (m *MockCallbackBrokenError) Apply(ctx context.Context, input, output any) errors.ValidationError { if input == nil { - // Apply callback if set if m.callback != nil { - return errors.Collection(m.callback(ctx, m.brokenErr)) + return m.callback(ctx, m.brokenErr) } - return errors.Collection(m.brokenErr) + return m.brokenErr } return m.MockRuleSet.Apply(ctx, input, output) } @@ -347,7 +347,7 @@ func (m *MockCallbackBrokenError) WithErrorCallback(fn errors.ErrorCallback) *Mo // MockNoErrors has Apply that succeeds even when it should fail (doesn't return errors for nil) type MockNoErrors struct{ testhelpers.MockRuleSet[int] } -func (m *MockNoErrors) Apply(ctx context.Context, input, output any) errors.ValidationErrorCollection { +func (m *MockNoErrors) Apply(ctx context.Context, input, output any) errors.ValidationError { return nil // Always succeeds - broken for testing nil error handling } diff --git a/pkg/testhelpers/withnil.go b/pkg/testhelpers/withnil.go index 60a6c6c..c433beb 100644 --- a/pkg/testhelpers/withnil.go +++ b/pkg/testhelpers/withnil.go @@ -35,8 +35,8 @@ func MustImplementWithNil[T any](t testing.TB, ruleSet rules.RuleSet[T]) { err := ruleSet.Apply(ctx, nil, &output) if err == nil { t.Error("Expected error when nil is provided without WithNil") - } else if err.First().Code() != errors.CodeNull { - t.Errorf("Expected error code to be CodeNull, got: %s", err.First().Code()) + } else if err.Code() != errors.CodeNull { + t.Errorf("Expected error code to be CodeNull, got: %s", err.Code()) } // Test with WithNil - should not error diff --git a/pkg/testhelpers/withnil_test.go b/pkg/testhelpers/withnil_test.go index b1870ea..e46f947 100644 --- a/pkg/testhelpers/withnil_test.go +++ b/pkg/testhelpers/withnil_test.go @@ -14,9 +14,9 @@ import ( // It implements RuleSet[int] directly to avoid inheriting WithNil from MockRuleSet type MockNoWithNilMethod struct{} -func (m *MockNoWithNilMethod) Apply(ctx context.Context, input, output any) errors.ValidationErrorCollection { +func (m *MockNoWithNilMethod) Apply(ctx context.Context, input, output any) errors.ValidationError { if input == nil { - return errors.Collection(errors.Errorf(errors.CodeNull, ctx, "null not allowed", "value cannot be null")) + return errors.Join(errors.Errorf(errors.CodeNull, ctx, "null not allowed", "value cannot be null")) } // Set output for valid input if outputPtr, ok := output.(*int); ok && outputPtr != nil { @@ -27,7 +27,7 @@ func (m *MockNoWithNilMethod) Apply(ctx context.Context, input, output any) erro return nil } -func (m *MockNoWithNilMethod) Evaluate(ctx context.Context, value int) errors.ValidationErrorCollection { +func (m *MockNoWithNilMethod) Evaluate(ctx context.Context, value int) errors.ValidationError { return nil } @@ -51,11 +51,11 @@ func (m *MockNoWithNilMethod) String() string { // mockNoWithNilMethodAny wraps MockNoWithNilMethod to implement RuleSet[any] type mockNoWithNilMethodAny struct{ inner *MockNoWithNilMethod } -func (m *mockNoWithNilMethodAny) Apply(ctx context.Context, input, output any) errors.ValidationErrorCollection { +func (m *mockNoWithNilMethodAny) Apply(ctx context.Context, input, output any) errors.ValidationError { return m.inner.Apply(ctx, input, output) } -func (m *mockNoWithNilMethodAny) Evaluate(ctx context.Context, value any) errors.ValidationErrorCollection { +func (m *mockNoWithNilMethodAny) Evaluate(ctx context.Context, value any) errors.ValidationError { return nil } @@ -88,10 +88,10 @@ func (m *MockNoWithNil) WithNil() rules.RuleSet[int] { // MockWrongNilErrorCode is a mock rule set that returns wrong error code for nil type MockWrongNilErrorCode struct{ testhelpers.MockRuleSet[int] } -func (m *MockWrongNilErrorCode) Apply(ctx context.Context, input, output any) errors.ValidationErrorCollection { +func (m *MockWrongNilErrorCode) Apply(ctx context.Context, input, output any) errors.ValidationError { if input == nil { // Return correct CodeNull for the first test (without WithNil) - return errors.Collection(errors.Errorf(errors.CodeNull, ctx, "null not allowed", "value cannot be null")) + return errors.Join(errors.Errorf(errors.CodeNull, ctx, "null not allowed", "value cannot be null")) } // For non-nil input, create a fresh mock and use its Apply mockRuleSet := testhelpers.NewMockRuleSet[int]() @@ -105,10 +105,10 @@ func (m *MockWrongNilErrorCode) WithNil() rules.RuleSet[int] { // MockWrongNilErrorCodeWithNil is a mock rule set that returns wrong error code for nil even with WithNil. type MockWrongNilErrorCodeWithNil struct{ testhelpers.MockRuleSet[int] } -func (m *MockWrongNilErrorCodeWithNil) Apply(ctx context.Context, input, output any) errors.ValidationErrorCollection { +func (m *MockWrongNilErrorCodeWithNil) Apply(ctx context.Context, input, output any) errors.ValidationError { if input == nil { // Return wrong error code instead of CodeNull, and don't set output to nil - return errors.Collection(errors.Errorf(errors.CodeUnknown, ctx, "unknown error", "value cannot be null")) + return errors.Join(errors.Errorf(errors.CodeUnknown, ctx, "unknown error", "value cannot be null")) } // For non-nil input, create a fresh mock and use its Apply mockRuleSet := testhelpers.NewMockRuleSet[int]() @@ -118,10 +118,10 @@ func (m *MockWrongNilErrorCodeWithNil) Apply(ctx context.Context, input, output // MockNilNotSet is a mock rule set that doesn't set output to nil when WithNil is used type MockNilNotSet struct{ testhelpers.MockRuleSet[int] } -func (m *MockNilNotSet) Apply(ctx context.Context, input, output any) errors.ValidationErrorCollection { +func (m *MockNilNotSet) Apply(ctx context.Context, input, output any) errors.ValidationError { if input == nil { // Return correct CodeNull for the first test (without WithNil) - return errors.Collection(errors.Errorf(errors.CodeNull, ctx, "null not allowed", "value cannot be null")) + return errors.Join(errors.Errorf(errors.CodeNull, ctx, "null not allowed", "value cannot be null")) } // For non-nil input, create a fresh mock and use its Apply mockRuleSet := testhelpers.NewMockRuleSet[int]() @@ -135,14 +135,14 @@ func (m *MockNilNotSet) WithNil() rules.RuleSet[int] { // MockNilNotSetWithNil is a mock rule set that doesn't set output to nil when WithNil is used. type MockNilNotSetWithNil struct{ testhelpers.MockRuleSet[int] } -func (m *MockNilNotSetWithNil) Apply(ctx context.Context, input, output any) errors.ValidationErrorCollection { +func (m *MockNilNotSetWithNil) Apply(ctx context.Context, input, output any) errors.ValidationError { if input == nil { // Don't set output to nil, just return success without setting output // This simulates a bug where WithNil is used but output isn't actually set to nil // We validate output is a pointer but don't set it to nil outputVal := reflect.ValueOf(output) if outputVal.Kind() != reflect.Ptr || outputVal.IsNil() { - return errors.Collection(errors.Errorf(errors.CodeInternal, ctx, "internal error", "Output must be a non-nil pointer")) + return errors.Join(errors.Errorf(errors.CodeInternal, ctx, "internal error", "Output must be a non-nil pointer")) } // Intentionally don't set output to nil - this is the bug we're testing return nil @@ -155,10 +155,10 @@ func (m *MockNilNotSetWithNil) Apply(ctx context.Context, input, output any) err // MockNilWrongReturnType is a mock rule set where WithNil returns wrong type type MockNilWrongReturnType struct{ testhelpers.MockRuleSet[int] } -func (m *MockNilWrongReturnType) Apply(ctx context.Context, input, output any) errors.ValidationErrorCollection { +func (m *MockNilWrongReturnType) Apply(ctx context.Context, input, output any) errors.ValidationError { if input == nil { // Return correct CodeNull for the first test (without WithNil) - return errors.Collection(errors.Errorf(errors.CodeNull, ctx, "null not allowed", "value cannot be null")) + return errors.Join(errors.Errorf(errors.CodeNull, ctx, "null not allowed", "value cannot be null")) } // For non-nil input, create a fresh mock and use its Apply mockRuleSet := testhelpers.NewMockRuleSet[int]() @@ -172,7 +172,7 @@ func (m *MockNilWrongReturnType) WithNil() string { // MockNilNoError is a mock rule set that doesn't return an error when nil is provided without WithNil type MockNilNoError struct{ testhelpers.MockRuleSet[int] } -func (m *MockNilNoError) Apply(ctx context.Context, input, output any) errors.ValidationErrorCollection { +func (m *MockNilNoError) Apply(ctx context.Context, input, output any) errors.ValidationError { if input == nil { // Don't return an error - this is the bug we're testing return nil @@ -189,7 +189,7 @@ func (m *MockNilNoError) WithNil() rules.RuleSet[int] { // MockNilNoErrorWithNil is a mock rule set that doesn't return an error when nil is provided even with WithNil. type MockNilNoErrorWithNil struct{ testhelpers.MockRuleSet[int] } -func (m *MockNilNoErrorWithNil) Apply(ctx context.Context, input, output any) errors.ValidationErrorCollection { +func (m *MockNilNoErrorWithNil) Apply(ctx context.Context, input, output any) errors.ValidationError { if input == nil { // Set output to nil correctly outputVal := reflect.ValueOf(output) @@ -210,10 +210,10 @@ func (m *MockNilNoErrorWithNil) Apply(ctx context.Context, input, output any) er // MockNilWrongCodeWithoutWithNil is a mock rule set that returns wrong error code when nil is provided without WithNil type MockNilWrongCodeWithoutWithNil struct{ testhelpers.MockRuleSet[int] } -func (m *MockNilWrongCodeWithoutWithNil) Apply(ctx context.Context, input, output any) errors.ValidationErrorCollection { +func (m *MockNilWrongCodeWithoutWithNil) Apply(ctx context.Context, input, output any) errors.ValidationError { if input == nil { // Return wrong error code instead of CodeNull - return errors.Collection(errors.Errorf(errors.CodeUnknown, ctx, "unknown error", "value cannot be null")) + return errors.Join(errors.Errorf(errors.CodeUnknown, ctx, "unknown error", "value cannot be null")) } // For non-nil input, create a fresh mock and use its Apply mockRuleSet := testhelpers.NewMockRuleSet[int]() @@ -227,7 +227,7 @@ func (m *MockNilWrongCodeWithoutWithNil) WithNil() rules.RuleSet[int] { // MockNilWrongCodeWithoutWithNilWithNil is a mock rule set that returns wrong error code when nil is provided even with WithNil. type MockNilWrongCodeWithoutWithNilWithNil struct{ testhelpers.MockRuleSet[int] } -func (m *MockNilWrongCodeWithoutWithNilWithNil) Apply(ctx context.Context, input, output any) errors.ValidationErrorCollection { +func (m *MockNilWrongCodeWithoutWithNilWithNil) Apply(ctx context.Context, input, output any) errors.ValidationError { if input == nil { // Set output to nil correctly outputVal := reflect.ValueOf(output) @@ -248,10 +248,10 @@ func (m *MockNilWrongCodeWithoutWithNilWithNil) Apply(ctx context.Context, input // MockNilWrongReturnCount is a mock rule set where WithNil returns wrong number of values type MockNilWrongReturnCount struct{ testhelpers.MockRuleSet[int] } -func (m *MockNilWrongReturnCount) Apply(ctx context.Context, input, output any) errors.ValidationErrorCollection { +func (m *MockNilWrongReturnCount) Apply(ctx context.Context, input, output any) errors.ValidationError { if input == nil { // Return correct CodeNull for the first test (without WithNil) - return errors.Collection(errors.Errorf(errors.CodeNull, ctx, "null not allowed", "value cannot be null")) + return errors.Join(errors.Errorf(errors.CodeNull, ctx, "null not allowed", "value cannot be null")) } // For non-nil input, create a fresh mock and use its Apply mockRuleSet := testhelpers.NewMockRuleSet[int]() @@ -266,7 +266,7 @@ func (m *MockNilWrongReturnCount) WithNil() (rules.RuleSet[int], string) { // MockNilWrongReturnCountWithNil is a mock rule set where WithNil returns wrong number of values. type MockNilWrongReturnCountWithNil struct{ testhelpers.MockRuleSet[int] } -func (m *MockNilWrongReturnCountWithNil) Apply(ctx context.Context, input, output any) errors.ValidationErrorCollection { +func (m *MockNilWrongReturnCountWithNil) Apply(ctx context.Context, input, output any) errors.ValidationError { if input == nil { // Set output to nil correctly outputVal := reflect.ValueOf(output) @@ -421,10 +421,10 @@ func TestMustImplementWithNil(t *testing.T) { // MockWithNilOnly is a mock rule set that has WithNil but not WithRequired type MockWithNilOnly struct{ testhelpers.MockRuleSet[int] } -func (m *MockWithNilOnly) Apply(ctx context.Context, input, output any) errors.ValidationErrorCollection { +func (m *MockWithNilOnly) Apply(ctx context.Context, input, output any) errors.ValidationError { if input == nil { // Return correct CodeNull for the first test (without WithNil) - return errors.Collection(errors.Errorf(errors.CodeNull, ctx, "null not allowed", "value cannot be null")) + return errors.Join(errors.Errorf(errors.CodeNull, ctx, "null not allowed", "value cannot be null")) } // For non-nil input, create a fresh mock and use its Apply mockRuleSet := testhelpers.NewMockRuleSet[int]() @@ -438,7 +438,7 @@ func (m *MockWithNilOnly) WithNil() rules.RuleSet[int] { // MockWithNilOnlyWithNil is a mock rule set that has WithNil but not WithRequired. type MockWithNilOnlyWithNil struct{ testhelpers.MockRuleSet[int] } -func (m *MockWithNilOnlyWithNil) Apply(ctx context.Context, input, output any) errors.ValidationErrorCollection { +func (m *MockWithNilOnlyWithNil) Apply(ctx context.Context, input, output any) errors.ValidationError { if input == nil { // Set output to nil correctly outputVal := reflect.ValueOf(output) @@ -459,9 +459,9 @@ func (m *MockWithNilOnlyWithNil) Apply(ctx context.Context, input, output any) e // MockWithRequiredNoWithNil is a mock rule set where WithRequired returns a rule set without WithNil type MockWithRequiredNoWithNil struct{ testhelpers.MockRuleSet[int] } -func (m *MockWithRequiredNoWithNil) Apply(ctx context.Context, input, output any) errors.ValidationErrorCollection { +func (m *MockWithRequiredNoWithNil) Apply(ctx context.Context, input, output any) errors.ValidationError { if input == nil { - return errors.Collection(errors.Errorf(errors.CodeNull, ctx, "null not allowed", "value cannot be null")) + return errors.Join(errors.Errorf(errors.CodeNull, ctx, "null not allowed", "value cannot be null")) } mockRuleSet := testhelpers.NewMockRuleSet[int]() return mockRuleSet.Apply(ctx, input, output) @@ -474,7 +474,7 @@ func (m *MockWithRequiredNoWithNil) WithNil() rules.RuleSet[int] { // MockWithRequiredNoWithNilWithNil is a mock rule set where WithRequired returns a rule set without WithNil. type MockWithRequiredNoWithNilWithNil struct{ testhelpers.MockRuleSet[int] } -func (m *MockWithRequiredNoWithNilWithNil) Apply(ctx context.Context, input, output any) errors.ValidationErrorCollection { +func (m *MockWithRequiredNoWithNilWithNil) Apply(ctx context.Context, input, output any) errors.ValidationError { if input == nil { outputVal := reflect.ValueOf(output) if outputVal.Kind() == reflect.Ptr && !outputVal.IsNil() { @@ -503,9 +503,9 @@ type MockWithRequiredNoWithNilRequired struct{ testhelpers.MockRuleSet[int] } // MockWithRequiredWrongType is a mock rule set where WithRequired returns wrong type type MockWithRequiredWrongType struct{ testhelpers.MockRuleSet[int] } -func (m *MockWithRequiredWrongType) Apply(ctx context.Context, input, output any) errors.ValidationErrorCollection { +func (m *MockWithRequiredWrongType) Apply(ctx context.Context, input, output any) errors.ValidationError { if input == nil { - return errors.Collection(errors.Errorf(errors.CodeNull, ctx, "null not allowed", "value cannot be null")) + return errors.Join(errors.Errorf(errors.CodeNull, ctx, "null not allowed", "value cannot be null")) } mockRuleSet := testhelpers.NewMockRuleSet[int]() return mockRuleSet.Apply(ctx, input, output) @@ -518,7 +518,7 @@ func (m *MockWithRequiredWrongType) WithNil() rules.RuleSet[int] { // MockWithRequiredWrongTypeWithNil is a mock rule set where WithRequired returns wrong type. type MockWithRequiredWrongTypeWithNil struct{ testhelpers.MockRuleSet[int] } -func (m *MockWithRequiredWrongTypeWithNil) Apply(ctx context.Context, input, output any) errors.ValidationErrorCollection { +func (m *MockWithRequiredWrongTypeWithNil) Apply(ctx context.Context, input, output any) errors.ValidationError { if input == nil { outputVal := reflect.ValueOf(output) if outputVal.Kind() == reflect.Ptr && !outputVal.IsNil() { @@ -541,9 +541,9 @@ func (m *MockWithRequiredWrongType) WithRequired() string { // MockWithRequiredWrongCount is a mock rule set where WithRequired returns wrong number of values type MockWithRequiredWrongCount struct{ testhelpers.MockRuleSet[int] } -func (m *MockWithRequiredWrongCount) Apply(ctx context.Context, input, output any) errors.ValidationErrorCollection { +func (m *MockWithRequiredWrongCount) Apply(ctx context.Context, input, output any) errors.ValidationError { if input == nil { - return errors.Collection(errors.Errorf(errors.CodeNull, ctx, "null not allowed", "value cannot be null")) + return errors.Join(errors.Errorf(errors.CodeNull, ctx, "null not allowed", "value cannot be null")) } mockRuleSet := testhelpers.NewMockRuleSet[int]() return mockRuleSet.Apply(ctx, input, output) @@ -556,7 +556,7 @@ func (m *MockWithRequiredWrongCount) WithNil() rules.RuleSet[int] { // MockWithRequiredWrongCountWithNil is a mock rule set where WithRequired returns wrong number of values. type MockWithRequiredWrongCountWithNil struct{ testhelpers.MockRuleSet[int] } -func (m *MockWithRequiredWrongCountWithNil) Apply(ctx context.Context, input, output any) errors.ValidationErrorCollection { +func (m *MockWithRequiredWrongCountWithNil) Apply(ctx context.Context, input, output any) errors.ValidationError { if input == nil { outputVal := reflect.ValueOf(output) if outputVal.Kind() == reflect.Ptr && !outputVal.IsNil() { @@ -579,9 +579,9 @@ func (m *MockWithRequiredWrongCount) WithRequired() (rules.RuleSet[int], string) // MockWithNilWrongTypeOnRequired is a mock where WithRequired returns a rule set whose WithNil returns wrong type type MockWithNilWrongTypeOnRequired struct{ testhelpers.MockRuleSet[int] } -func (m *MockWithNilWrongTypeOnRequired) Apply(ctx context.Context, input, output any) errors.ValidationErrorCollection { +func (m *MockWithNilWrongTypeOnRequired) Apply(ctx context.Context, input, output any) errors.ValidationError { if input == nil { - return errors.Collection(errors.Errorf(errors.CodeNull, ctx, "null not allowed", "value cannot be null")) + return errors.Join(errors.Errorf(errors.CodeNull, ctx, "null not allowed", "value cannot be null")) } mockRuleSet := testhelpers.NewMockRuleSet[int]() return mockRuleSet.Apply(ctx, input, output) @@ -594,7 +594,7 @@ func (m *MockWithNilWrongTypeOnRequired) WithNil() rules.RuleSet[int] { // MockWithNilWrongTypeOnRequiredWithNil is a mock where WithRequired returns a rule set whose WithNil returns wrong type. type MockWithNilWrongTypeOnRequiredWithNil struct{ testhelpers.MockRuleSet[int] } -func (m *MockWithNilWrongTypeOnRequiredWithNil) Apply(ctx context.Context, input, output any) errors.ValidationErrorCollection { +func (m *MockWithNilWrongTypeOnRequiredWithNil) Apply(ctx context.Context, input, output any) errors.ValidationError { if input == nil { outputVal := reflect.ValueOf(output) if outputVal.Kind() == reflect.Ptr && !outputVal.IsNil() { @@ -624,9 +624,9 @@ func (m *MockWithNilWrongTypeOnRequiredRequired) WithNil() string { // MockWithNilWrongCountOnRequired is a mock where WithRequired returns a rule set whose WithNil returns wrong count type MockWithNilWrongCountOnRequired struct{ testhelpers.MockRuleSet[int] } -func (m *MockWithNilWrongCountOnRequired) Apply(ctx context.Context, input, output any) errors.ValidationErrorCollection { +func (m *MockWithNilWrongCountOnRequired) Apply(ctx context.Context, input, output any) errors.ValidationError { if input == nil { - return errors.Collection(errors.Errorf(errors.CodeNull, ctx, "null not allowed", "value cannot be null")) + return errors.Join(errors.Errorf(errors.CodeNull, ctx, "null not allowed", "value cannot be null")) } mockRuleSet := testhelpers.NewMockRuleSet[int]() return mockRuleSet.Apply(ctx, input, output) @@ -639,7 +639,7 @@ func (m *MockWithNilWrongCountOnRequired) WithNil() rules.RuleSet[int] { // MockWithNilWrongCountOnRequiredWithNil is a mock where WithRequired returns a rule set whose WithNil returns wrong count. type MockWithNilWrongCountOnRequiredWithNil struct{ testhelpers.MockRuleSet[int] } -func (m *MockWithNilWrongCountOnRequiredWithNil) Apply(ctx context.Context, input, output any) errors.ValidationErrorCollection { +func (m *MockWithNilWrongCountOnRequiredWithNil) Apply(ctx context.Context, input, output any) errors.ValidationError { if input == nil { outputVal := reflect.ValueOf(output) if outputVal.Kind() == reflect.Ptr && !outputVal.IsNil() { @@ -669,9 +669,9 @@ func (m *MockWithNilWrongCountOnRequiredRequired) WithNil() (rules.RuleSet[int], // MockWithBothButError is a mock where both WithNil and WithRequired are set but Apply returns error type MockWithBothButError struct{ testhelpers.MockRuleSet[int] } -func (m *MockWithBothButError) Apply(ctx context.Context, input, output any) errors.ValidationErrorCollection { +func (m *MockWithBothButError) Apply(ctx context.Context, input, output any) errors.ValidationError { if input == nil { - return errors.Collection(errors.Errorf(errors.CodeNull, ctx, "null not allowed", "value cannot be null")) + return errors.Join(errors.Errorf(errors.CodeNull, ctx, "null not allowed", "value cannot be null")) } mockRuleSet := testhelpers.NewMockRuleSet[int]() return mockRuleSet.Apply(ctx, input, output) @@ -684,7 +684,7 @@ func (m *MockWithBothButError) WithNil() rules.RuleSet[int] { // MockWithBothButErrorWithNil is a mock where both WithNil and WithRequired are set but Apply returns error. type MockWithBothButErrorWithNil struct{ testhelpers.MockRuleSet[int] } -func (m *MockWithBothButErrorWithNil) Apply(ctx context.Context, input, output any) errors.ValidationErrorCollection { +func (m *MockWithBothButErrorWithNil) Apply(ctx context.Context, input, output any) errors.ValidationError { if input == nil { outputVal := reflect.ValueOf(output) if outputVal.Kind() == reflect.Ptr && !outputVal.IsNil() { @@ -714,10 +714,10 @@ func (m *MockWithBothButErrorRequired) WithNil() rules.RuleSet[int] { // MockWithBothButErrorBoth is a mock where both WithNil and WithRequired are set but Apply returns error. type MockWithBothButErrorBoth struct{ testhelpers.MockRuleSet[int] } -func (m *MockWithBothButErrorBoth) Apply(ctx context.Context, input, output any) errors.ValidationErrorCollection { +func (m *MockWithBothButErrorBoth) Apply(ctx context.Context, input, output any) errors.ValidationError { if input == nil { // Return an error even though both WithNil and WithRequired are set - this is the bug we're testing - return errors.Collection(errors.Errorf(errors.CodeUnknown, ctx, "unknown error", "unexpected error")) + return errors.Join(errors.Errorf(errors.CodeUnknown, ctx, "unknown error", "unexpected error")) } mockRuleSet := testhelpers.NewMockRuleSet[int]() return mockRuleSet.Apply(ctx, input, output)