Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 3 additions & 7 deletions internal/util/withnil.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
12 changes: 6 additions & 6 deletions internal/util/withnil_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
246 changes: 157 additions & 89 deletions pkg/errors/collection.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,141 +2,209 @@ 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
}
}
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
}
}
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...)
}
Loading