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
64 changes: 64 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ A type-safe validation framework for Go using generics. This package provides co
- **Flexible**: Wrap go-playground/validator, ozzo-validation, or ANY validation library
- **Zero dependencies**: Core package has no external dependencies
- **Battle-tested**: Optional playground package provides 100+ validators from go-playground
- **Error Detection**: All errors are wrapped in `validation.Error` for easy identification
- **Clean API**: Simple, readable validation code

## Philosophy
Expand Down Expand Up @@ -97,6 +98,69 @@ validation.Validate(age, validation.MinLength(3))
validation.Validate(age, validation.Range("0", "120"))
```

## Error Detection

All validation errors in Protego are wrapped in a `validation.Error` type, making it easy to detect and handle Protego-specific errors:

```go
import (
"errors"
"github.com/quantumcycle/protego/validation"
"github.com/quantumcycle/protego/playground"
)

func ProcessUser(input CreateUserInput) error {
err := input.Validate()
if err != nil {
// Check if this is a Protego validation error
if validation.IsValidationError(err) {
// Handle validation errors specifically
return fmt.Errorf("validation failed: %w", err)
}
// Handle other types of errors
return fmt.Errorf("unexpected error: %w", err)
}
// Process valid input
return nil
}
```

### Error Detection Features

- **Type Detection**: Use `validation.IsValidationError(err)` to check if an error came from Protego
- **Error Wrapping**: All validators wrap errors using `validation.Error`, including playground validators
- **Error Unwrapping**: Supports Go's standard `errors.Unwrap()` and `errors.Is()` functions
- **Preserved Messages**: Original error messages remain unchanged for backward compatibility

### Examples

```go
// Detect validation errors from core validators
err := validation.Validate("", validation.Required[string]())
if validation.IsValidationError(err) {
fmt.Println("Protego validation error:", err.Error()) // Output: required
}

// Detect validation errors from playground validators
err = validation.Validate("invalid-email", playground.IsEmail)
if validation.IsValidationError(err) {
fmt.Println("Email validation failed:", err.Error())
}

// Use with errors.Join for multiple validations
err = errors.Join(
validation.Validate("", validation.Required[string]()),
validation.Validate("ab", validation.MinLength(3)),
)
// Check if any are validation errors
if validation.IsValidationError(err) {
fmt.Println("Contains validation errors")
}

// Error unwrapping works
originalErr := errors.Unwrap(err)
```

## Available Validators

### Required Validators
Expand Down
77 changes: 77 additions & 0 deletions playground/error_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package playground_test

import (
"testing"

. "github.com/onsi/gomega"

"github.com/quantumcycle/protego/playground"
"github.com/quantumcycle/protego/validation"
)

func TestPlaygroundValidatorsReturnValidationError(t *testing.T) {
t.Run("IsEmail validator returns ValidationError", func(t *testing.T) {
g := NewWithT(t)
err := validation.Validate("not-an-email", playground.IsEmail)
g.Expect(err).ToNot(BeNil())
g.Expect(validation.IsValidationError(err)).To(BeTrue())
})

t.Run("IsUUID4 validator returns ValidationError", func(t *testing.T) {
g := NewWithT(t)
err := validation.Validate("not-a-uuid", playground.IsUUID4)
g.Expect(err).ToNot(BeNil())
g.Expect(validation.IsValidationError(err)).To(BeTrue())
})

t.Run("IsURL validator returns ValidationError", func(t *testing.T) {
g := NewWithT(t)
err := validation.Validate("not-a-url", playground.IsURL)
g.Expect(err).ToNot(BeNil())
g.Expect(validation.IsValidationError(err)).To(BeTrue())
})

t.Run("IsIPv4 validator returns ValidationError", func(t *testing.T) {
g := NewWithT(t)
err := validation.Validate("not-an-ip", playground.IsIPv4)
g.Expect(err).ToNot(BeNil())
g.Expect(validation.IsValidationError(err)).To(BeTrue())
})

t.Run("FromTag validator returns ValidationError", func(t *testing.T) {
g := NewWithT(t)
customValidator := playground.FromTag[string]("uuid")
err := validation.Validate("not-a-uuid", customValidator)
g.Expect(err).ToNot(BeNil())
g.Expect(validation.IsValidationError(err)).To(BeTrue())
})

t.Run("FromTagWithMessage validator returns ValidationError", func(t *testing.T) {
g := NewWithT(t)
customValidator := playground.FromTagWithMessage[string]("uuid", "custom error message")
err := validation.Validate("not-a-uuid", customValidator)
g.Expect(err).ToNot(BeNil())
g.Expect(err.Error()).To(Equal("custom error message"))
g.Expect(validation.IsValidationError(err)).To(BeTrue())
})
}

func TestPlaygroundValidatorsPass(t *testing.T) {
t.Run("IsEmail validator passes for valid email", func(t *testing.T) {
g := NewWithT(t)
err := validation.Validate("test@example.com", playground.IsEmail)
g.Expect(err).To(BeNil())
})

t.Run("IsUUID4 validator passes for valid UUID", func(t *testing.T) {
g := NewWithT(t)
err := validation.Validate("f47ac10b-58cc-4372-a567-0e02b2c3d479", playground.IsUUID4)
g.Expect(err).To(BeNil())
})

t.Run("IsURL validator passes for valid URL", func(t *testing.T) {
g := NewWithT(t)
err := validation.Validate("https://example.com", playground.IsURL)
g.Expect(err).To(BeNil())
})
}
9 changes: 5 additions & 4 deletions playground/playground.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,6 @@
package playground

import (
"fmt"

"github.com/go-playground/validator/v10"

"github.com/quantumcycle/protego/validation"
Expand All @@ -43,7 +41,10 @@ var sharedValidator = validator.New()
// See https://pkg.go.dev/github.com/go-playground/validator/v10 for all available tags.
func FromTag[T any](tag string) validation.Validator[T] {
return func(v T) error {
return sharedValidator.Var(v, tag)
if err := sharedValidator.Var(v, tag); err != nil {
return validation.WrapError(err)
}
return nil
}
}

Expand All @@ -57,7 +58,7 @@ func FromTag[T any](tag string) validation.Validator[T] {
func FromTagWithMessage[T any](tag, message string) validation.Validator[T] {
return func(v T) error {
if err := sharedValidator.Var(v, tag); err != nil {
return fmt.Errorf("%s", message)
return validation.NewValidationError(message)
}
return nil
}
Expand Down
36 changes: 18 additions & 18 deletions validation/collection.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ func In[T comparable](caseInsensitive bool, allowed ...T) Validator[T] {
} else if slices.Contains(allowed, v) {
return nil
}
return fmt.Errorf("must be one of: %v", allowed)
return NewValidationError(fmt.Sprintf("must be one of: %v", allowed))
}
}

Expand All @@ -53,11 +53,11 @@ func NotIn[T comparable](caseInsensitive bool, forbidden ...T) Validator[T] {
vs := strings.ToLower(fmt.Sprint(v))
for _, f := range forbidden {
if strings.ToLower(fmt.Sprint(f)) == vs {
return fmt.Errorf("cannot be one of: %v", forbidden)
return NewValidationError(fmt.Sprintf("cannot be one of: %v", forbidden))
}
}
} else if slices.Contains(forbidden, v) {
return fmt.Errorf("cannot be one of: %v", forbidden)
return NewValidationError(fmt.Sprintf("cannot be one of: %v", forbidden))
}
return nil
}
Expand All @@ -75,7 +75,7 @@ func Each[T any](elementValidator Validator[T]) Validator[[]T] {
var errs []error
for i, v := range values {
if err := elementValidator(v); err != nil {
errs = append(errs, fmt.Errorf("index %d: %w", i, err))
errs = append(errs, WrapError(fmt.Errorf("index %d: %w", i, err)))
}
}
return errors.Join(errs...)
Expand All @@ -90,7 +90,7 @@ func Each[T any](elementValidator Validator[T]) Validator[[]T] {
func NotEmpty[T any]() Validator[[]T] {
return func(values []T) error {
if len(values) == 0 {
return fmt.Errorf("cannot be empty")
return NewValidationError("cannot be empty")
}
return nil
}
Expand All @@ -104,7 +104,7 @@ func NotEmpty[T any]() Validator[[]T] {
func MinItems[T any](minimum int) Validator[[]T] {
return func(values []T) error {
if len(values) < minimum {
return fmt.Errorf("must have at least %d items", minimum)
return NewValidationError(fmt.Sprintf("must have at least %d items", minimum))
}
return nil
}
Expand All @@ -118,7 +118,7 @@ func MinItems[T any](minimum int) Validator[[]T] {
func MaxItems[T any](maximum int) Validator[[]T] {
return func(values []T) error {
if len(values) > maximum {
return fmt.Errorf("must have at most %d items", maximum)
return NewValidationError(fmt.Sprintf("must have at most %d items", maximum))
}
return nil
}
Expand All @@ -134,7 +134,7 @@ func UniqueItems[T comparable]() Validator[[]T] {
seen := make(map[T]bool)
for i, v := range values {
if seen[v] {
return fmt.Errorf("duplicate item at index %d: %v", i, v)
return NewValidationError(fmt.Sprintf("duplicate item at index %d: %v", i, v))
}
seen[v] = true
}
Expand Down Expand Up @@ -182,13 +182,13 @@ func ValidateStringMap(m map[string]string, allowExtra bool, rules ...MapKeyRule

value, exists := m[rule.key]
if !exists && rule.required {
return fmt.Errorf("key %q is required", rule.key)
return NewValidationError(fmt.Sprintf("key %q is required", rule.key))
}

if exists {
for _, validator := range rule.validators {
if err := validator(value); err != nil {
return fmt.Errorf("key %q: %w", rule.key, err)
return WrapError(fmt.Errorf("key %q: %w", rule.key, err))
}
}
}
Expand All @@ -198,7 +198,7 @@ func ValidateStringMap(m map[string]string, allowExtra bool, rules ...MapKeyRule
if !allowExtra {
for key := range m {
if !validated[key] {
return fmt.Errorf("key %q not expected", key)
return NewValidationError(fmt.Sprintf("key %q not expected", key))
}
}
}
Expand Down Expand Up @@ -229,13 +229,13 @@ func ValidateAnyMap(m map[string]any, allowExtra bool, rules ...MapKeyRule[any])

value, exists := m[rule.key]
if !exists && rule.required {
return fmt.Errorf("key %q is required", rule.key)
return NewValidationError(fmt.Sprintf("key %q is required", rule.key))
}

if exists {
for _, validator := range rule.validators {
if err := validator(value); err != nil {
return fmt.Errorf("key %q: %w", rule.key, err)
return WrapError(fmt.Errorf("key %q: %w", rule.key, err))
}
}
}
Expand All @@ -245,7 +245,7 @@ func ValidateAnyMap(m map[string]any, allowExtra bool, rules ...MapKeyRule[any])
if !allowExtra {
for key := range m {
if !validated[key] {
return fmt.Errorf("key %q not expected", key)
return NewValidationError(fmt.Sprintf("key %q not expected", key))
}
}
}
Expand All @@ -263,7 +263,7 @@ func StringValidator(validator Validator[string]) Validator[any] {
return func(v any) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("must be a string")
return NewValidationError("must be a string")
}
return validator(str)
}
Expand All @@ -286,7 +286,7 @@ func IntValidator(validator Validator[int]) Validator[any] {
case int64:
return validator(int(val))
default:
return fmt.Errorf("must be a number")
return NewValidationError("must be a number")
}
}
}
Expand All @@ -310,7 +310,7 @@ func FloatValidator(validator Validator[float64]) Validator[any] {
case int64:
return validator(float64(val))
default:
return fmt.Errorf("must be a number")
return NewValidationError("must be a number")
}
}
}
Expand All @@ -328,7 +328,7 @@ func BoolValidator(validator Validator[bool]) Validator[any] {
return func(v any) error {
val, ok := v.(bool)
if !ok {
return fmt.Errorf("must be a boolean")
return NewValidationError("must be a boolean")
}
return validator(val)
}
Expand Down
Loading
Loading