diff --git a/pkg/govy/README.md b/pkg/govy/README.md new file mode 100644 index 0000000..7a0643a --- /dev/null +++ b/pkg/govy/README.md @@ -0,0 +1,180 @@ +# Validation + +Package validation implements a functional API for consistent, +type safe validation. +It puts heavy focus on end user errors readability, +providing means of crafting clear and information-rich error messages. + +Validation pipeline is immutable and lazily loaded. + +- Immutable, as changing the pipeline through chained functions, + will return a new pipeline. + It allows extended reusability of validation components. +- Lazily loaded, as properties are extracted through getter functions, + which are only called when you call the `Validate` method. + Functional approach allows validation components to only be called when + needed. + You should define your pipeline once and call it + whenever you validate instances of your entity. + +All that has been made possible by the introduction of generics in Go. +Prior to that, there wasn't really any viable way to create type safe +validation API. +Although the current state of type inference is somewhat clunky, +the API can only improve in time when generics support in Go is further +extended. + +## NOTE: Work in progress + +Although already battle tested through SLO hellfire, +this library is still a work in progress. +The principles and the API at its core won't change, +but the details and capabilities might hopefully will. +Contributions and suggestions are most welcome! + +## Usage + +**This README goes through an abstract overview of the library. \ +Refer to [example_test.go](./example_test.go) +for a hands-on tutorial with runnable examples.** + +### Legend + +- [Validator](#validator) +- [Property rules](#property-rules) + - [PropertyRules](#propertyrules) _(single property)_ + - [PropertyRulesForEach](#propertyrulesforeach) _(slice of properties)_ +- [Rule](#rule) + - [SingleRule](#singlerule) + - [RuleSet](#ruleset) +- [Errors](#errors) + - [ValidatorError](#validatorerror) + - [PropertyError](#propertyerror) + - [RuleError](#ruleerror) +- [FAQ](#faq) + +### Validator + +Validator aggregates [property rules](#property-rules) into a single validation scenario, +most commonly associated with an entity, like `struct`. + +If any property rules fail [ValidatorError](#validatorerror) is returned. + +### Property rules + +When validating structured data, namely `struct`, +each structure consists of multiple properties. +For `struct`, these will be its fields. + +Most commonly, property has its name and value. +Property name should be derived from the struct +representation visible by the errors consumer, +this will most likely be JSON format. + +Nested properties are represented by paths, +where each property is delimited by `.`. +Arrays are represented by `[]`. +Let's examine a simple teacher/student example: + +```go +package university + +type Teacher struct { + Name string `json:"name"` + Students []Student `json:"students"` +} + +type Student struct { + Index string +} +``` + +We can distinguish the following property paths: + +- `name` +- `students` +- `students[0].Index` _(let's assume there's only a single student)_ + +If any property rule fails [PropertyError](#propertyerror) is returned. + +#### PropertyRules + +`PropertyRules` aggregates [rules](#rule) for a single property. + +#### PropertyRulesForEach + +`PropertyRulesForEach` is an extension of [PropertyRules](#propertyrules), +it provides means of defining rules for each property in a slice. + +Currently, it only works with slices, maps are not supported. + +### Rule + +Rules validate a concrete value. +If a rule is not met it returns [RuleError](#ruleerror). + +#### SingleRule + +This is the most basic validation building block. +Its error code can be set using `WithErrorCode` function and its error message can +also be enhanced using `WithDetails` function. +Details are delimited by `;` character. + +#### RuleSet + +Rule sets are used to aggregate multiple [SingleRule](#singlerule) +into a single validation rule. +It wraps any and all errors returned from single rules in a container which is later +on unpacked. If you use either `WithErrorCode` or `WithDetails` functions, each error +will be extended with the provided details and error code. + +### Errors + +Each validation level defines an error which adds further details of what went wrong. + +#### ValidatorError + +Adds top level entity name, following our teacher example, +it would be simply `teacher`. +Although that once again depends on how your end use perceives this entity. +It wraps multiple [PropertyError](#propertyerror). + +#### PropertyError + +Adds both property name and value. Property value is converted to a string +representation. It wraps multiple [RuleError](#ruleerror). + +#### RuleError + +The most basic building block for validation errors, associated with a single +failing [SingleRule](#singlerule). +It conveys an error message and [ErrorCode](#error-codes). + +#### Error codes + +To aid the process of testing, `ErrorCode` has been introduced along +with a helper functions `WithErrorCode` to associate [Rule](#rule) with an error +code and `AddCode` to associate multiple error codes with a single [Rule](#rule). +Multiple error codes are delimited by `:`, +similar to how wrapped errors are represented in Go. + +To check if `ErrorCode` is part if a given validation error, use `HasErrorCode`. + +## FAQ + +### Why not use existing validation library? + +Existing, established solutions are mostly based on struct tags and heavily +utilize reflection. +This leaves type safety an issue to be solved and handled by developers. +For simple use cases, covered by predefined validation functions, +this solutions works well enough. +However when adding custom validation rules, +type casting has to be heavily utilized, +and it becomes increasingly harder to track what exactly is being validated. +Another issue is the readability of the errors, +it's often hard or even impossible to shape the error to the developer liking. + +### Acknowledgements + +Heavily inspired by [C# FluentValidation](https://docs.fluentvalidation.net/). diff --git a/pkg/govy/cascade_mode.go b/pkg/govy/cascade_mode.go new file mode 100644 index 0000000..7d306df --- /dev/null +++ b/pkg/govy/cascade_mode.go @@ -0,0 +1,11 @@ +package validation + +// CascadeMode defines how validation should behave when an error is encountered. +type CascadeMode uint + +const ( + // CascadeModeContinue will continue validation after first error. + CascadeModeContinue CascadeMode = iota + // CascadeModeStop will stop validation on first error encountered. + CascadeModeStop +) diff --git a/pkg/govy/comparable.go b/pkg/govy/comparable.go new file mode 100644 index 0000000..7797a33 --- /dev/null +++ b/pkg/govy/comparable.go @@ -0,0 +1,108 @@ +package validation + +import ( + "fmt" + + "github.com/pkg/errors" + "golang.org/x/exp/constraints" +) + +func EqualTo[T comparable](compared T) SingleRule[T] { + msg := fmt.Sprintf(comparisonFmt, cmpEqualTo, compared) + return NewSingleRule(func(v T) error { + if v != compared { + return errors.New(msg) + } + return nil + }). + WithErrorCode(ErrorCodeEqualTo). + WithDescription(msg) +} + +func NotEqualTo[T comparable](compared T) SingleRule[T] { + msg := fmt.Sprintf(comparisonFmt, cmpNotEqualTo, compared) + return NewSingleRule(func(v T) error { + if v == compared { + return errors.New(msg) + } + return nil + }). + WithErrorCode(ErrorCodeNotEqualTo). + WithDescription(msg) +} + +func GreaterThan[T constraints.Ordered](n T) SingleRule[T] { + return orderedComparisonRule(cmpGreaterThan, n). + WithErrorCode(ErrorCodeGreaterThan) +} + +func GreaterThanOrEqualTo[T constraints.Ordered](n T) SingleRule[T] { + return orderedComparisonRule(cmpGreaterThanOrEqual, n). + WithErrorCode(ErrorCodeGreaterThanOrEqualTo) +} + +func LessThan[T constraints.Ordered](n T) SingleRule[T] { + return orderedComparisonRule(cmpLessThan, n). + WithErrorCode(ErrorCodeLessThan) +} + +func LessThanOrEqualTo[T constraints.Ordered](n T) SingleRule[T] { + return orderedComparisonRule(cmpLessThanOrEqual, n). + WithErrorCode(ErrorCodeLessThanOrEqualTo) +} + +var comparisonFmt = "should be %s '%v'" + +func orderedComparisonRule[T constraints.Ordered](op comparisonOperator, compared T) SingleRule[T] { + msg := fmt.Sprintf(comparisonFmt, op, compared) + return NewSingleRule(func(v T) error { + var passed bool + switch op { + case cmpGreaterThan: + passed = v > compared + case cmpGreaterThanOrEqual: + passed = v >= compared + case cmpLessThan: + passed = v < compared + case cmpLessThanOrEqual: + passed = v <= compared + default: + passed = false + } + if !passed { + return errors.New(msg) + } + return nil + }).WithDescription(msg) +} + +type comparisonOperator uint8 + +const ( + cmpEqualTo comparisonOperator = iota + cmpNotEqualTo + cmpGreaterThan + cmpGreaterThanOrEqual + cmpLessThan + cmpLessThanOrEqual +) + +func (c comparisonOperator) String() string { + //exhaustive: enforce + switch c { + case cmpEqualTo: + return "equal to" + case cmpNotEqualTo: + return "not equal to" + case cmpGreaterThan: + return "greater than" + case cmpGreaterThanOrEqual: + return "greater than or equal to" + case cmpLessThan: + return "less than" + case cmpLessThanOrEqual: + return "less than or equal to" + default: + return "unknown" + } +} diff --git a/pkg/govy/comparable_test.go b/pkg/govy/comparable_test.go new file mode 100644 index 0000000..4c8cdbf --- /dev/null +++ b/pkg/govy/comparable_test.go @@ -0,0 +1,95 @@ +package validation + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEqualTo(t *testing.T) { + t.Run("passes", func(t *testing.T) { + err := EqualTo(1.1).Validate(1.1) + assert.NoError(t, err) + }) + t.Run("fails", func(t *testing.T) { + err := EqualTo(1.1).Validate(1.3) + require.Error(t, err) + assert.EqualError(t, err, "should be equal to '1.1'") + assert.True(t, HasErrorCode(err, ErrorCodeEqualTo)) + }) +} + +func TestNotEqualTo(t *testing.T) { + t.Run("passes", func(t *testing.T) { + err := NotEqualTo(1.1).Validate(1.3) + assert.NoError(t, err) + }) + t.Run("fails", func(t *testing.T) { + err := NotEqualTo(1.1).Validate(1.1) + require.Error(t, err) + assert.EqualError(t, err, "should be not equal to '1.1'") + assert.True(t, HasErrorCode(err, ErrorCodeNotEqualTo)) + }) +} + +func TestGreaterThan(t *testing.T) { + t.Run("passes", func(t *testing.T) { + err := GreaterThan(1).Validate(2) + assert.NoError(t, err) + }) + t.Run("fails", func(t *testing.T) { + for n, v := range map[int]int{1: 1, 4: 2} { + err := GreaterThan(n).Validate(v) + require.Error(t, err) + assert.EqualError(t, err, fmt.Sprintf("should be greater than '%v'", n)) + assert.True(t, HasErrorCode(err, ErrorCodeGreaterThan)) + } + }) +} + +func TestGreaterThanOrEqual(t *testing.T) { + t.Run("passes", func(t *testing.T) { + for n, v := range map[int]int{1: 1, 2: 4} { + err := GreaterThanOrEqualTo(n).Validate(v) + assert.NoError(t, err) + } + }) + t.Run("fails", func(t *testing.T) { + err := GreaterThanOrEqualTo(4).Validate(2) + require.Error(t, err) + assert.EqualError(t, err, "should be greater than or equal to '4'") + assert.True(t, HasErrorCode(err, ErrorCodeGreaterThanOrEqualTo)) + }) +} + +func TestLessThan(t *testing.T) { + t.Run("passes", func(t *testing.T) { + err := LessThan(4).Validate(2) + assert.NoError(t, err) + }) + t.Run("fails", func(t *testing.T) { + for n, v := range map[int]int{1: 1, 2: 4} { + err := LessThan(n).Validate(v) + require.Error(t, err) + assert.EqualError(t, err, fmt.Sprintf("should be less than '%v'", n)) + assert.True(t, HasErrorCode(err, ErrorCodeLessThan)) + } + }) +} + +func TestLessThanOrEqual(t *testing.T) { + t.Run("passes", func(t *testing.T) { + for n, v := range map[int]int{1: 1, 4: 2} { + err := LessThanOrEqualTo(n).Validate(v) + assert.NoError(t, err) + } + }) + t.Run("fails", func(t *testing.T) { + err := LessThanOrEqualTo(2).Validate(4) + require.Error(t, err) + assert.EqualError(t, err, "should be less than or equal to '2'") + assert.True(t, HasErrorCode(err, ErrorCodeLessThanOrEqualTo)) + }) +} diff --git a/pkg/govy/doc.go b/pkg/govy/doc.go new file mode 100644 index 0000000..9f97f89 --- /dev/null +++ b/pkg/govy/doc.go @@ -0,0 +1,2 @@ +// Package validation implements a functional API for consistent struct level validation. +package validation diff --git a/pkg/govy/duration.go b/pkg/govy/duration.go new file mode 100644 index 0000000..0fe3f79 --- /dev/null +++ b/pkg/govy/duration.go @@ -0,0 +1,20 @@ +package validation + +import ( + "fmt" + "time" + + "github.com/pkg/errors" +) + +func DurationPrecision(precision time.Duration) SingleRule[time.Duration] { + msg := fmt.Sprintf("duration must be defined with %s precision", precision) + return NewSingleRule(func(v time.Duration) error { + if v.Nanoseconds()%int64(precision) != 0 { + return errors.New(msg) + } + return nil + }). + WithErrorCode(ErrorCodeDurationPrecision). + WithDescription(msg) +} diff --git a/pkg/govy/duration_test.go b/pkg/govy/duration_test.go new file mode 100644 index 0000000..ca4b9d1 --- /dev/null +++ b/pkg/govy/duration_test.go @@ -0,0 +1,56 @@ +package validation + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestDurationPrecision(t *testing.T) { + tests := []struct { + name string + duration time.Duration + precision time.Duration + expected error + }{ + { + name: "valid precision 1ns", + duration: time.Duration(123456), + precision: time.Nanosecond, + expected: nil, + }, + { + name: "valid precision 1m", + duration: time.Hour + time.Minute, + precision: time.Minute, + expected: nil, + }, + { + name: "invalid precision 1m1s", + duration: time.Minute + time.Second, + precision: time.Minute, + expected: NewRuleError("duration must be defined with 1m0s precision", ErrorCodeDurationPrecision), + }, + { + name: "invalid precision", + duration: time.Duration(123456), + precision: 10 * time.Nanosecond, + expected: NewRuleError("duration must be defined with 10ns precision", ErrorCodeDurationPrecision), + }, + { + name: "minute precision", + duration: time.Duration(123456), + precision: time.Minute, + expected: NewRuleError("duration must be defined with 1m0s precision", ErrorCodeDurationPrecision), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rule := DurationPrecision(tt.precision) + result := rule.Validate(tt.duration) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/pkg/govy/error_codes.go b/pkg/govy/error_codes.go new file mode 100644 index 0000000..3cbc5f6 --- /dev/null +++ b/pkg/govy/error_codes.go @@ -0,0 +1,40 @@ +package validation + +type ErrorCode = string + +const ( + ErrorCodeRequired ErrorCode = "required" + ErrorCodeTransform ErrorCode = "transform" + ErrorCodeForbidden ErrorCode = "forbidden" + ErrorCodeEqualTo ErrorCode = "equal_to" + ErrorCodeNotEqualTo ErrorCode = "not_equal_to" + ErrorCodeGreaterThan ErrorCode = "greater_than" + ErrorCodeGreaterThanOrEqualTo ErrorCode = "greater_than_or_equal_to" + ErrorCodeLessThan ErrorCode = "less_than" + ErrorCodeLessThanOrEqualTo ErrorCode = "less_than_or_equal_to" + ErrorCodeStringNotEmpty ErrorCode = "string_not_empty" + ErrorCodeStringMatchRegexp ErrorCode = "string_match_regexp" + ErrorCodeStringDenyRegexp ErrorCode = "string_deny_regexp" + ErrorCodeStringDescription ErrorCode = "string_description" + ErrorCodeStringIsDNSSubdomain ErrorCode = "string_is_dns_subdomain" + ErrorCodeStringASCII ErrorCode = "string_ascii" + ErrorCodeStringURL ErrorCode = "string_url" + ErrorCodeStringUUID ErrorCode = "string_uuid" + ErrorCodeStringJSON ErrorCode = "string_json" + ErrorCodeStringContains ErrorCode = "string_contains" + ErrorCodeStringStartsWith ErrorCode = "string_starts_with" + ErrorCodeStringLength ErrorCode = "string_length" + ErrorCodeStringMinLength ErrorCode = "string_min_length" + ErrorCodeStringMaxLength ErrorCode = "string_max_length" + ErrorCodeSliceLength ErrorCode = "slice_length" + ErrorCodeSliceMinLength ErrorCode = "slice_min_length" + ErrorCodeSliceMaxLength ErrorCode = "slice_max_length" + ErrorCodeMapLength ErrorCode = "map_length" + ErrorCodeMapMinLength ErrorCode = "map_min_length" + ErrorCodeMapMaxLength ErrorCode = "map_max_length" + ErrorCodeOneOf ErrorCode = "one_of" + ErrorCodeMutuallyExclusive ErrorCode = "mutually_exclusive" + ErrorCodeSliceUnique ErrorCode = "slice_unique" + ErrorCodeURL ErrorCode = "url" + ErrorCodeDurationPrecision ErrorCode = "duration_precision" +) diff --git a/pkg/govy/errors.go b/pkg/govy/errors.go new file mode 100644 index 0000000..ac6519a --- /dev/null +++ b/pkg/govy/errors.go @@ -0,0 +1,362 @@ +package validation + +import ( + "encoding/json" + "fmt" + "reflect" + "sort" + "strings" +) + +func NewValidatorError(errs PropertyErrors) *ValidatorError { + return &ValidatorError{Errors: errs} +} + +type ValidatorError struct { + Errors PropertyErrors `json:"errors"` + Name string `json:"name"` +} + +func (e *ValidatorError) WithName(name string) *ValidatorError { + e.Name = name + return e +} + +func (e *ValidatorError) Error() string { + b := strings.Builder{} + b.WriteString("Validation") + if e.Name != "" { + b.WriteString(" for ") + b.WriteString(e.Name) + } + b.WriteString(" has failed for the following properties:\n") + JoinErrors(&b, e.Errors, strings.Repeat(" ", 2)) + return b.String() +} + +type PropertyErrors []*PropertyError + +func (e PropertyErrors) Error() string { + b := strings.Builder{} + JoinErrors(&b, e, "") + return b.String() +} + +func (e PropertyErrors) HideValue() PropertyErrors { + for _, err := range e { + _ = err.HideValue() + } + return e +} + +// Sort should be always called after Aggregate. +func (e PropertyErrors) Sort() PropertyErrors { + if len(e) == 0 { + return e + } + sort.Slice(e, func(i, j int) bool { + e1, e2 := e[i], e[j] + if e1.PropertyName != e2.PropertyName { + return e1.PropertyName < e2.PropertyName + } + if e1.PropertyValue != e2.PropertyValue { + return e1.PropertyValue < e2.PropertyValue + } + if e1.IsKeyError != e2.IsKeyError { + return e1.IsKeyError + } + return e1.IsSliceElementError + }) + return e +} + +func (e PropertyErrors) Aggregate() PropertyErrors { + if len(e) == 0 { + return nil + } + agg := make(PropertyErrors, 0, len(e)) +outer: + for _, e1 := range e { + for _, e2 := range agg { + if e1.Equal(e2) { + e2.Errors = append(e2.Errors, e1.Errors...) + continue outer + } + } + agg = append(agg, e1) + } + return agg +} + +func NewPropertyError(propertyName string, propertyValue interface{}, errs ...error) *PropertyError { + return &PropertyError{ + PropertyName: propertyName, + PropertyValue: propertyValueString(propertyValue), + Errors: unpackRuleErrors(errs, make([]*RuleError, 0, len(errs))), + } +} + +type PropertyError struct { + PropertyName string `json:"propertyName"` + PropertyValue string `json:"propertyValue"` + // IsKeyError is set to true if the error was created through map key validation. + // PropertyValue in this scenario will be the key value, equal to the last element of PropertyName path. + IsKeyError bool `json:"isKeyError,omitempty"` + // IsSliceElementError is set to true if the error was created through slice element validation. + IsSliceElementError bool `json:"isSliceElementError,omitempty"` + Errors []*RuleError `json:"errors"` +} + +func (e *PropertyError) Error() string { + b := new(strings.Builder) + indent := "" + if e.PropertyName != "" { + fmt.Fprintf(b, "'%s'", e.PropertyName) + if e.PropertyValue != "" { + if e.IsKeyError { + fmt.Fprintf(b, " with key '%s'", e.PropertyValue) + } else { + fmt.Fprintf(b, " with value '%s'", e.PropertyValue) + } + } + b.WriteString(":\n") + indent = strings.Repeat(" ", 2) + } + JoinErrors(b, e.Errors, indent) + return b.String() +} + +func (e *PropertyError) Equal(cmp *PropertyError) bool { + return e.PropertyName == cmp.PropertyName && + e.PropertyValue == cmp.PropertyValue && + e.IsKeyError == cmp.IsKeyError && + e.IsSliceElementError == cmp.IsSliceElementError +} + +const ( + propertyNameSeparator = "." + hiddenValue = "[hidden]" +) + +func (e *PropertyError) PrependPropertyName(name string) *PropertyError { + sep := propertyNameSeparator + if e.IsSliceElementError && strings.HasPrefix(e.PropertyName, "[") { + sep = "" + } + e.PropertyName = concatStrings(name, e.PropertyName, sep) + return e +} + +// HideValue hides the property value from [PropertyError.Error] and also hides it from. +func (e *PropertyError) HideValue() *PropertyError { + sv := propertyValueString(e.PropertyValue) + e.PropertyValue = "" + for _, err := range e.Errors { + _ = err.HideValue(sv) + } + return e +} + +// NewRuleError creates a new [RuleError] with the given message and optional error codes. +// Error codes are added according to the rules defined by [RuleError.AddCode]. +func NewRuleError(message string, codes ...ErrorCode) *RuleError { + ruleError := &RuleError{Message: message} + for _, code := range codes { + ruleError = ruleError.AddCode(code) + } + return ruleError +} + +type RuleError struct { + Message string `json:"error"` + Code ErrorCode `json:"code,omitempty"` +} + +func (r *RuleError) Error() string { + return r.Message +} + +const ErrorCodeSeparator = ":" + +// AddCode extends the [RuleError] with the given error code. +// Codes are prepended, the last code in chain is always the first one set. +// Example: +// +// ruleError.AddCode("code").AddCode("another").AddCode("last") +// +// This will result in 'last:another:code' [ErrorCode]. +func (r *RuleError) AddCode(code ErrorCode) *RuleError { + r.Code = concatStrings(code, r.Code, ErrorCodeSeparator) + return r +} + +// HideValue replaces all occurrences of stringValue in the [RuleError.Message] with an '*' characters. +func (r *RuleError) HideValue(stringValue string) *RuleError { + r.Message = strings.ReplaceAll(r.Message, stringValue, hiddenValue) + return r +} + +func concatStrings(pre, post, sep string) string { + if pre == "" { + return post + } + if post == "" { + return pre + } + return pre + sep + post +} + +// HasErrorCode checks if an error contains given [ErrorCode]. +// It supports all [validation] errors. +func HasErrorCode(err error, code ErrorCode) bool { + switch v := err.(type) { + case PropertyErrors: + for _, e := range v { + if HasErrorCode(e, code) { + return true + } + } + return false + case *ValidatorError: + for _, e := range v.Errors { + if HasErrorCode(e, code) { + return true + } + } + return false + case *RuleError: + codes := strings.Split(v.Code, ErrorCodeSeparator) + for i := range codes { + if code == codes[i] { + return true + } + } + case *PropertyError: + for _, e := range v.Errors { + if HasErrorCode(e, code) { + return true + } + } + } + return false +} + +var newLineReplacer = strings.NewReplacer("\n", "\\n", "\r", "\\r") + +// propertyValueString returns the string representation of the given value. +// Structs, interfaces, maps and slices are converted to compacted JSON strings. +// It tries to improve readability by: +// - limiting the string to 100 characters +// - removing leading and trailing whitespaces +// - escaping newlines +// If value is a struct implementing [fmt.Stringer] String method will be used +// only if the struct does not contain any JSON tags. +func propertyValueString(v interface{}) string { + if v == nil { + return "" + } + rv := reflect.ValueOf(v) + ft := reflect.Indirect(rv) + var s string + switch ft.Kind() { + case reflect.Interface, reflect.Map, reflect.Slice: + if reflect.ValueOf(v).IsZero() { + break + } + raw, _ := json.Marshal(v) + s = string(raw) + case reflect.Struct: + if reflect.ValueOf(v).IsZero() { + break + } + if stringer, ok := v.(fmt.Stringer); ok && !hasJSONTags(v, rv.Kind() == reflect.Pointer) { + s = stringer.String() + break + } + raw, _ := json.Marshal(v) + s = string(raw) + case reflect.Invalid: + return "" + default: + s = fmt.Sprint(ft.Interface()) + } + s = limitString(s, 100) + s = strings.TrimSpace(s) + s = newLineReplacer.Replace(s) + return s +} + +func hasJSONTags(v interface{}, isPointer bool) bool { + t := reflect.TypeOf(v) + if isPointer { + t = t.Elem() + } + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + if _, hasTag := field.Tag.Lookup("json"); hasTag { + return true + } + } + return false +} + +// ruleSetError is a container for transferring multiple errors reported by [RuleSet]. +// It is intentionally not exported as it is only an intermediate stage before the +// aggregated errors are flattened. +type ruleSetError []error + +func (r ruleSetError) Error() string { + b := new(strings.Builder) + JoinErrors(b, r, "") + return b.String() +} + +func JoinErrors[T error](b *strings.Builder, errs []T, indent string) { + for i, err := range errs { + buildErrorMessage(b, err.Error(), indent) + if i < len(errs)-1 { + b.WriteString("\n") + } + } +} + +const listPoint = "- " + +func buildErrorMessage(b *strings.Builder, errMsg, indent string) { + b.WriteString(indent) + if !strings.HasPrefix(errMsg, listPoint) { + b.WriteString(listPoint) + } + // Indent the whole error message. + errMsg = strings.ReplaceAll(errMsg, "\n", "\n"+indent) + b.WriteString(errMsg) +} + +func limitString(s string, limit int) string { + if len(s) > limit { + return s[:limit] + "..." + } + return s +} + +// unpackRuleErrors unpacks error messages recursively scanning [ruleSetError] if it is detected. +func unpackRuleErrors(errs []error, ruleErrors []*RuleError) []*RuleError { + for _, err := range errs { + switch v := err.(type) { + case ruleSetError: + ruleErrors = unpackRuleErrors(v, ruleErrors) + case *RuleError: + ruleErrors = append(ruleErrors, v) + default: + ruleErrors = append(ruleErrors, &RuleError{Message: v.Error()}) + } + } + return ruleErrors +} + +func NewRequiredError() *RuleError { + return NewRuleError( + "property is required but was empty", + ErrorCodeRequired, + ) +} diff --git a/pkg/govy/errors_test.go b/pkg/govy/errors_test.go new file mode 100644 index 0000000..04c9492 --- /dev/null +++ b/pkg/govy/errors_test.go @@ -0,0 +1,344 @@ +package validation + +import ( + "embed" + "fmt" + "path/filepath" + "testing" + + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +//go:embed test_data +var errorsTestData embed.FS + +func TestValidatorError(t *testing.T) { + for name, err := range map[string]*ValidatorError{ + "no_name": { + Errors: PropertyErrors{ + { + PropertyName: "this", + PropertyValue: "123", + Errors: []*RuleError{{Message: "this is an error"}}, + }, + { + PropertyName: "that", + Errors: []*RuleError{{Message: "that is an error"}}, + }, + }, + }, + "with_name": { + Name: "Teacher", + Errors: PropertyErrors{ + { + PropertyName: "this", + PropertyValue: "123", + Errors: []*RuleError{{Message: "this is an error"}}, + }, + { + PropertyName: "that", + Errors: []*RuleError{{Message: "that is an error"}}, + }, + }, + }, + "prop_no_name": { + Errors: PropertyErrors{ + { + Errors: []*RuleError{{Message: "no name"}}, + }, + { + PropertyName: "that", + Errors: []*RuleError{{Message: "that is an error"}}, + }, + }, + }, + } { + t.Run(name, func(t *testing.T) { + assert.EqualError(t, err, expectedErrorOutput(t, fmt.Sprintf("validator_error_%s.txt", name))) + }) + } +} + +func TestNewPropertyError(t *testing.T) { + t.Run("string value", func(t *testing.T) { + err := NewPropertyError("name", "value", + &RuleError{Message: "top", Code: "1"}, + ruleSetError{ + &RuleError{Message: "rule1", Code: "2"}, + &RuleError{Message: "rule2", Code: "3"}, + }, + &RuleError{Message: "top", Code: "4"}, + ) + assert.Equal(t, &PropertyError{ + PropertyName: "name", + PropertyValue: "value", + Errors: []*RuleError{ + {Message: "top", Code: "1"}, + {Message: "rule1", Code: "2"}, + {Message: "rule2", Code: "3"}, + {Message: "top", Code: "4"}, + }, + }, err) + }) + for name, test := range map[string]struct { + InputValue interface{} + ExpectedValue string + }{ + "map": { + InputValue: map[string]string{"key": "value"}, + ExpectedValue: `{"key":"value"}`, + }, + "struct": { + InputValue: struct { + V string `json:"that"` + }{ + V: "this", + }, + ExpectedValue: `{"that":"this"}`, + }, + "slice": { + InputValue: []string{"value"}, + ExpectedValue: `["value"]`, + }, + "integer": { + InputValue: 0, + ExpectedValue: "0", + }, + "float": { + InputValue: 10.1, + ExpectedValue: "10.1", + }, + "boolean": { + InputValue: false, + ExpectedValue: "false", + }, + "pointer": { + InputValue: ptr(10.2), + ExpectedValue: "10.2", + }, + "initialized nil": { + InputValue: func() *float64 { return nil }(), + ExpectedValue: "", + }, + "nil": { + InputValue: nil, + ExpectedValue: "", + }, + "blank lines": { + InputValue: ` SELECT value FROM my-table WHERE value = "abc" `, + ExpectedValue: `SELECT value FROM my-table WHERE value = "abc"`, + }, + "multiline": { + InputValue: ` +SELECT value FROM +my-table WHERE value = "abc" +`, + ExpectedValue: "SELECT value FROM\\nmy-table WHERE value = \"abc\"", + }, + "carriage return": { + InputValue: "return\rcarriage", + ExpectedValue: "return\\rcarriage", + }, + } { + t.Run(name, func(t *testing.T) { + err := NewPropertyError( + "name", + test.InputValue, + &RuleError{Message: "msg"}) + assert.Equal(t, &PropertyError{ + PropertyName: "name", + PropertyValue: test.ExpectedValue, + Errors: []*RuleError{{Message: "msg"}}, + }, err) + }) + } +} + +type ( + stringerWithTags struct { + This string `json:"this"` + That string `json:"THAT"` + } + stringerWithoutTags struct { + This string + That string + } +) + +func (s stringerWithTags) String() string { return s.This + "_" + s.That } +func (s stringerWithoutTags) String() string { return s.This + "_" + s.That } + +func TestPropertyError(t *testing.T) { + for name, value := range map[string]interface{}{ + "string": "default", + "slice": []string{"this", "that"}, + "map": map[string]string{"this": "that"}, + "struct": struct { + This string `json:"this"` + That string `json:"THAT"` + }{This: "this", That: "that"}, + "stringer_with_json_tags": stringerWithTags{ + This: "this", That: "that", + }, + "stringer_without_json_tags": stringerWithoutTags{ + This: "this", That: "that", + }, + "stringer_pointer": &stringerWithoutTags{ + This: "this", That: "that", + }, + } { + t.Run(name, func(t *testing.T) { + err := &PropertyError{ + PropertyName: "metadata.name", + PropertyValue: propertyValueString(value), + Errors: []*RuleError{ + {Message: "what a shame this happened"}, + {Message: "this is outrageous..."}, + {Message: "here's another error"}, + }, + } + assert.EqualError(t, err, expectedErrorOutput(t, fmt.Sprintf("property_error_%s.txt", name))) + }) + } + t.Run("no name provided", func(t *testing.T) { + err := &PropertyError{ + Errors: []*RuleError{ + {Message: "what a shame this happened"}, + {Message: "this is outrageous..."}, + {Message: "here's another error"}, + }, + } + assert.EqualError(t, err, expectedErrorOutput(t, "property_error_no_name.txt")) + }) +} + +func TestPropertyError_PrependPropertyName(t *testing.T) { + for _, test := range []struct { + PropertyError *PropertyError + InputName string + ExpectedName string + }{ + { + PropertyError: &PropertyError{}, + }, + { + PropertyError: &PropertyError{PropertyName: "test"}, + ExpectedName: "test", + }, + { + PropertyError: &PropertyError{}, + InputName: "new", + ExpectedName: "new", + }, + { + PropertyError: &PropertyError{PropertyName: "original"}, + InputName: "added", + ExpectedName: "added.original", + }, + } { + assert.Equal(t, test.ExpectedName, test.PropertyError.PrependPropertyName(test.InputName).PropertyName) + } +} + +func TestRuleError(t *testing.T) { + for _, test := range []struct { + RuleError *RuleError + InputCode ErrorCode + ExpectedCode ErrorCode + }{ + { + RuleError: NewRuleError("test"), + }, + { + RuleError: NewRuleError("test", "code"), + ExpectedCode: "code", + }, + { + RuleError: NewRuleError("test"), + InputCode: "code", + ExpectedCode: "code", + }, + { + RuleError: NewRuleError("test", "original"), + InputCode: "added", + ExpectedCode: "added:original", + }, + { + RuleError: NewRuleError("test", "code-1", "code-2"), + ExpectedCode: "code-2:code-1", + }, + { + RuleError: NewRuleError("test", "original-1", "original-2"), + InputCode: "added", + ExpectedCode: "added:original-2:original-1", + }, + } { + result := test.RuleError.AddCode(test.InputCode) + assert.Equal(t, test.RuleError.Message, result.Message) + assert.Equal(t, test.ExpectedCode, result.Code) + } +} + +func TestMultiRuleError(t *testing.T) { + err := ruleSetError{ + errors.New("this is just a test!"), + errors.New("another error..."), + errors.New("that is just fatal."), + } + assert.EqualError(t, err, expectedErrorOutput(t, "multi_error.txt")) +} + +func TestHasErrorCode(t *testing.T) { + for _, test := range []struct { + Error error + Code ErrorCode + HasErrorCode bool + }{ + { + Error: nil, + Code: "code", + HasErrorCode: false, + }, + { + Error: errors.New("code"), + Code: "code", + HasErrorCode: false, + }, + { + Error: &RuleError{Code: "another"}, + Code: "code", + HasErrorCode: false, + }, + { + Error: &RuleError{Code: "another:this"}, + Code: "code", + HasErrorCode: false, + }, + { + Error: &RuleError{Code: "another:code:this"}, + Code: "code", + HasErrorCode: true, + }, + { + Error: &PropertyError{Errors: []*RuleError{{Code: "another"}}}, + Code: "code", + HasErrorCode: false, + }, + { + Error: &PropertyError{Errors: []*RuleError{{Code: "this:another"}, {}, {Code: "another:code:this"}}}, + Code: "code", + HasErrorCode: true, + }, + } { + assert.Equal(t, test.HasErrorCode, HasErrorCode(test.Error, test.Code)) + } +} + +func expectedErrorOutput(t *testing.T, name string) string { + t.Helper() + data, err := errorsTestData.ReadFile(filepath.Join("test_data", name)) + require.NoError(t, err) + return string(data) +} diff --git a/pkg/govy/example_test.go b/pkg/govy/example_test.go new file mode 100644 index 0000000..a81efaf --- /dev/null +++ b/pkg/govy/example_test.go @@ -0,0 +1,960 @@ +// nolint: lll +package validation_test + +import ( + "fmt" + "os" + "regexp" + "time" + + "github.com/nobl9/go-yaml" + + "github.com/nobl9/nobl9-go/internal/validation" +) + +type Teacher struct { + Name string `json:"name"` + Age time.Duration `json:"age"` + Students []Student `json:"students"` + MiddleName *string `json:"middleName,omitempty"` + University University `json:"university"` +} + +type University struct { + Name string `json:"name"` + Address string `json:"address"` +} + +type Student struct { + Index string `json:"index"` +} + +type Tutoring struct { + StudentIndexToTeacher map[string]Teacher `json:"studentIndexToTeacher"` +} + +const year = 24 * 365 * time.Hour + +// In order to create a new [Validator] use [New] constructor. +// Let's define simple [PropertyRules] for [Teacher.Name]. +// For now, it will be always failing. +func ExampleNew() { + v := validation.New[Teacher]( + validation.For(func(t Teacher) string { return t.Name }). + Rules(validation.NewSingleRule(func(name string) error { return fmt.Errorf("always fails") })), + ) + + err := v.Validate(Teacher{}) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation has failed for the following properties: + // - always fails +} + +// To associate [Validator] with an entity name use [Validator.WithName] function. +// When any of the rules fails, the error will contain the entity name you've provided. +func ExampleValidator_WithName() { + v := validation.New[Teacher]( + validation.For(func(t Teacher) string { return t.Name }). + Rules(validation.NewSingleRule(func(name string) error { return fmt.Errorf("always fails") })), + ).WithName("Teacher") + + err := v.Validate(Teacher{}) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation for Teacher has failed for the following properties: + // - always fails +} + +// You can also add [Validator] name during runtime, +// by calling [ValidatorError.WithName] function on the returned error. +// +// NOTE: We left the previous "Teacher" name assignment, to demonstrate that +// the [ValidatorError.WithName] function call will shadow it. +// +// NOTE: This would also work: +// +// err := v.WithName("Jake").Validate(Teacher{}) +// +// Validation package, aside from errors handling, +// tries to follow immutability principle. Calling any function on [Validator] +// will not change its previous declaration (unless you assign it back to 'v'). +func ExampleValidatorError_WithName() { + v := validation.New[Teacher]( + validation.For(func(t Teacher) string { return t.Name }). + Rules(validation.NewSingleRule(func(name string) error { return fmt.Errorf("always fails") })), + ).WithName("Teacher") + + err := v.Validate(Teacher{}) + if err != nil { + fmt.Println(err.WithName("Jake")) + } + + // Output: + // Validation for Jake has failed for the following properties: + // - always fails +} + +// [Validator] rules can be evaluated on condition, to specify the predicate use [Validator.When] function. +// +// In this example, validation for [Teacher] instance will only be evaluated +// if the [Age] property is less than 50 years. +func ExampleValidator_When() { + v := validation.New[Teacher]( + validation.For(func(t Teacher) string { return t.Name }). + Rules(validation.NewSingleRule(func(name string) error { return fmt.Errorf("always fails") })), + ). + When(func(t Teacher) bool { return t.Age < (50 * year) }) + + // Prepare teachers. + teacherTom := Teacher{ + Name: "Tom", + Age: 51 * year, + } + teacherJerry := Teacher{ + Name: "Jerry", + Age: 30 * year, + } + + // Run validation. + err := v.Validate(teacherTom) + if err != nil { + fmt.Println(err.WithName("Tom")) + } + err = v.Validate(teacherJerry) + if err != nil { + fmt.Println(err.WithName("Jerry")) + } + + // Output: + // Validation for Jerry has failed for the following properties: + // - always fails +} + +// So far we've been using a very simple [PropertyRules] instance: +// +// validation.For(func(t Teacher) string { return t.Name }). +// Rules(validation.NewSingleRule(func(name string) error { return fmt.Errorf("always fails") })) +// +// The error message returned by this property rule does not tell us +// which property is failing. +// Let's change that by adding property name using [PropertyRules.WithName]. +// +// We can also change the [Rule] to be something more real. +// Validation package comes with a number of predefined [Rule], we'll use +// [EqualTo] which accepts a single argument, value to compare with. +func ExamplePropertyRules_WithName() { + v := validation.New[Teacher]( + validation.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Rules(validation.EqualTo("Tom")), + ).WithName("Teacher") + + teacher := Teacher{ + Name: "Jake", + Age: 51 * year, + } + + err := v.Validate(teacher) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation for Teacher has failed for the following properties: + // - 'name' with value 'Jake': + // - should be equal to 'Tom' +} + +// [For] constructor creates new [PropertyRules] instance. +// It's only argument, [PropertyGetter] is used to extract the property value. +// It works fine for direct values, but falls short when working with pointers. +// Often times we use pointers to indicate that a property is optional, +// or we want to discern between nil and zero values. +// In either case we want our validation rules to work on direct values, +// not the pointer, otherwise we'd have to always check if pointer != nil. +// +// [ForPointer] constructor can be used to solve this problem and allow +// us to work with the underlying value in our rules. +// Under the hood it wraps [PropertyGetter] and safely extracts the underlying value. +// If the value was nil, it will not attempt to evaluate any rules for this property. +// The rationale for that is it doesn't make sense to evaluate any rules for properties +// which are essentially empty. The only rule that makes sense in this context is to +// ensure the property is required. +// We'll learn about a way to achieve that in the next example: [ExamplePropertyRules_Required]. +// +// Let's define a rule for [Teacher.MiddleName] property. +// Not everyone has to have a middle name, that's why we've defined this field +// as a pointer to string, rather than a string itself. +func ExampleForPointer() { + v := validation.New[Teacher]( + validation.ForPointer(func(t Teacher) *string { return t.MiddleName }). + WithName("middleName"). + Rules(validation.StringMaxLength(5)), + ).WithName("Teacher") + + middleName := "Thaddeus" + teacher := Teacher{ + Name: "Jake", + Age: 51 * year, + MiddleName: &middleName, + } + + err := v.Validate(teacher) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation for Teacher has failed for the following properties: + // - 'middleName' with value 'Thaddeus': + // - length must be less than or equal to 5 +} + +// By default, when [PropertyRules] is constructed using [ForPointer] +// it will skip validation of the property if the pointer is nil. +// To enforce a value is set for pointer use [PropertyRules.Required]. +// +// You may ask yourself why not just use [validation.Required] rule instead? +// If we were to do that, we'd be forced to operate on pointer in all of our rules. +// Other than checking if the pointer is nil, there aren't any rules which would +// benefit from working on the pointer instead of the underlying value. +// +// If you want to also make sure the underlying value is filled, +// i.e. it's not a zero value, you can also use [validation.Required] rule +// on top of [PropertyRules.Required]. +// +// [PropertyRules.Required] when used with [For] constructor, will ensure +// the property does not contain a zero value. +// +// NOTE: [PropertyRules.Required] is introducing a short circuit. +// If the assertion fails, validation will stop and return [validation.ErrorCodeRequired]. +// None of the rules you've defined would be evaluated. +// +// NOTE: Placement of [PropertyRules.Required] does not matter, +// it's not evaluated in a sequential loop, unlike standard [Rule]. +// However, we recommend you always place it below [PropertyRules.WithName] +// to make your rules more readable. +func ExamplePropertyRules_Required() { + alwaysFailingRule := validation.NewSingleRule(func(string) error { + return fmt.Errorf("always fails") + }) + + v := validation.New[Teacher]( + validation.ForPointer(func(t Teacher) *string { return t.MiddleName }). + WithName("middleName"). + Required(). + Rules(alwaysFailingRule), + validation.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Required(). + Rules(alwaysFailingRule), + ).WithName("Teacher") + + teacher := Teacher{ + Name: "", + Age: 51 * year, + MiddleName: nil, + } + + err := v.Validate(teacher) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation for Teacher has failed for the following properties: + // - 'middleName': + // - property is required but was empty + // - 'name': + // - property is required but was empty +} + +// While [ForPointer] will by default omit validation for nil pointers, +// it might be useful to have a similar behavior for optional properties +// which are direct values. +// [PropertyRules.OmitEmpty] will do the trick. +// +// NOTE: [PropertyRules.OmitEmpty] will have no effect on pointers handled +// by [ForPointer], as they already behave in the same way. +func ExamplePropertyRules_OmitEmpty() { + alwaysFailingRule := validation.NewSingleRule(func(string) error { + return fmt.Errorf("always fails") + }) + + v := validation.New[Teacher]( + validation.For(func(t Teacher) string { return t.Name }). + WithName("name"). + OmitEmpty(). + Rules(alwaysFailingRule), + validation.ForPointer(func(t Teacher) *string { return t.MiddleName }). + WithName("middleName"). + Rules(alwaysFailingRule), + ).WithName("Teacher") + + teacher := Teacher{ + Name: "", + Age: 51 * year, + MiddleName: nil, + } + + err := v.Validate(teacher) + if err == nil { + fmt.Println("no error! we skipped 'name' validation and 'middleName' is implicitly skipped") + } + + // Output: + // no error! we skipped 'name' validation and 'middleName' is implicitly skipped +} + +// If you want to access the value of the entity you're writing the [Validator] for, +// you can use [GetSelf] function which is a convenience [PropertyGetter] that returns self. +// Note that we don't call [PropertyRules.WithName] here, +// as we're comparing two properties in our top level, [Teacher] scope. +// +// You can provide your own rules using [NewSingleRule] constructor. +// It returns new [SingleRule] instance which wraps your validation function. +func ExampleGetSelf() { + customRule := validation.NewSingleRule(func(v Teacher) error { + return fmt.Errorf("now I have access to the whole teacher") + }) + + v := validation.New[Teacher]( + validation.For(validation.GetSelf[Teacher]()). + Rules(customRule), + ).WithName("Teacher") + + teacher := Teacher{ + Name: "Jake", + Age: 51 * year, + } + + err := v.Validate(teacher) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation for Teacher has failed for the following properties: + // - now I have access to the whole teacher +} + +// You can use [SingleRule.WithDetails] to add additional details to the error message. +// This allows you to extend existing rules by adding your use case context. +// Let's give a regex validation some more clarity. +func ExampleSingleRule_WithDetails() { + v := validation.New[Teacher]( + validation.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Rules(validation.StringMatchRegexp(regexp.MustCompile("^(Tom|Jerry)$")). + WithDetails("Teacher can be either Tom or Jerry :)")), + ).WithName("Teacher") + + teacher := Teacher{ + Name: "Jake", + Age: 51 * year, + } + + err := v.Validate(teacher) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation for Teacher has failed for the following properties: + // - 'name' with value 'Jake': + // - string must match regular expression: '^(Tom|Jerry)$'; Teacher can be either Tom or Jerry :) +} + +// When testing, it can be tedious to always rely on error messages as these can change over time. +// Enter [ErrorCode], which is a simple string type alias used to ease testing, +// but also potentially allow third parties to integrate with your validation results. +// Use [SingleRule.WithErrorCode] to associate [ErrorCode] with a [SingleRule]. +// Notice that our modified version of [StringMatchRegexp] will now return a new [ErrorCode]. +// Predefined rules have [ErrorCode] already associated with them. +// To view the list of predefined [ErrorCode] checkout error_codes.go file. +func ExampleSingleRule_WithErrorCode() { + v := validation.New[Teacher]( + validation.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Rules(validation.StringMatchRegexp(regexp.MustCompile("^(Tom|Jerry)$")). + WithDetails("Teacher can be either Tom or Jerry :)"). + WithErrorCode("custom_code")), + ).WithName("Teacher") + + teacher := Teacher{ + Name: "Jake", + Age: 51 * year, + } + + err := v.Validate(teacher) + if err != nil { + propertyErrors := err.Errors + ruleErrors := propertyErrors[0].Errors + fmt.Println(ruleErrors[0].Code) + } + + // Output: + // custom_code +} + +// Sometimes it's useful to build a [Rule] using other rules. +// To do that we'll use [RuleSet] and [NewRuleSet] constructor. +// RuleSet is a simple container for multiple [Rule]. +// It is later on unpacked and each [RuleError] is reported separately. +// When [RuleSet.WithErrorCode] or [RuleSet.WithDetails] are used, +// error code and details are added to each [RuleError]. +// Note that validation package uses similar syntax to wrapped errors in Go; +// a ':' delimiter is used to chain error codes together. +func ExampleRuleSet() { + teacherNameRule := validation.NewRuleSet[string]( + validation.StringLength(1, 5), + validation.StringMatchRegexp(regexp.MustCompile("^(Tom|Jerry)$")). + WithDetails("Teacher can be either Tom or Jerry :)"), + ). + WithErrorCode("teacher_name"). + WithDetails("I will add that to both rules!") + + v := validation.New[Teacher]( + validation.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Rules(teacherNameRule), + ).WithName("Teacher") + + teacher := Teacher{ + Name: "Jonathan", + Age: 51 * year, + } + + err := v.Validate(teacher) + if err != nil { + propertyErrors := err.Errors + ruleErrors := propertyErrors[0].Errors + fmt.Printf("Error codes: %s, %s\n\n", ruleErrors[0].Code, ruleErrors[1].Code) + fmt.Println(err) + } + + // nolint: lll + // Output: + // Error codes: teacher_name:string_length, teacher_name:string_match_regexp + // + // Validation for Teacher has failed for the following properties: + // - 'name' with value 'Jonathan': + // - length must be between 1 and 5; I will add that to both rules! + // - string does not match regular expression: '^(Tom|Jerry)$'; Teacher can be either Tom or Jerry :); I will add that to both rules! +} + +// To inspect if an error contains a given [validation.ErrorCode], use [HasErrorCode] function. +// This function will also return true if the expected [ErrorCode] +// is part of a chain of wrapped error codes. +// In this example we're dealing with two error code chains: +// - 'teacher_name:string_length' +// - 'teacher_name:string_match_regexp' +func ExampleHasErrorCode() { + teacherNameRule := validation.NewRuleSet[string]( + validation.StringLength(1, 5), + validation.StringMatchRegexp(regexp.MustCompile("^(Tom|Jerry)$")), + ). + WithErrorCode("teacher_name") + + v := validation.New[Teacher]( + validation.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Rules(teacherNameRule), + ).WithName("Teacher") + + teacher := Teacher{ + Name: "Jonathan", + Age: 51 * year, + } + + err := v.Validate(teacher) + if err != nil { + for _, code := range []validation.ErrorCode{ + "teacher_name", + "string_length", + "string_match_regexp", + } { + if validation.HasErrorCode(err, code) { + fmt.Println("Has error code:", code) + } + } + } + + // Output: + // Has error code: teacher_name + // Has error code: string_length + // Has error code: string_match_regexp +} + +// Sometimes you need top level context, +// but you want to scope the error to a specific, nested property. +// One of the ways to do that is to use [NewPropertyError] +// and return [PropertyError] from your validation rule. +// Note that you can still use [ErrorCode] and pass [RuleError] to the constructor. +// You can pass any number of [RuleError]. +func ExampleNewPropertyError() { + v := validation.New[Teacher]( + validation.For(validation.GetSelf[Teacher]()). + Rules(validation.NewSingleRule(func(t Teacher) error { + if t.Name == "Jake" { + return validation.NewPropertyError( + "name", + t.Name, + validation.NewRuleError("name cannot be Jake", "error_code_jake"), + validation.NewRuleError("you can pass me too!")) + } + return nil + })), + ).WithName("Teacher") + + teacher := Teacher{ + Name: "Jake", + Age: 51 * year, + } + + err := v.Validate(teacher) + if err != nil { + propertyErrors := err.Errors + ruleErrors := propertyErrors[0].Errors + fmt.Printf("Error code: %s\n\n", ruleErrors[0].Code) + fmt.Println(err) + } + + // Output: + // Error code: error_code_jake + // + // Validation for Teacher has failed for the following properties: + // - 'name' with value 'Jake': + // - name cannot be Jake + // - you can pass me too! +} + +// So far we've defined validation rules for simple, top-level properties. +// What If we want to define validation rules for nested properties? +// We can use [PropertyRules.Include] to include another [Validator] in our [PropertyRules]. +// +// Let's extend our [Teacher] struct to include a nested [University] property. +// [University] in of itself is another struct with its own validation rules. +// +// Notice how the nested property path is automatically built for you, +// each segment separated by a dot. +func ExamplePropertyRules_Include() { + universityValidation := validation.New[University]( + validation.For(func(u University) string { return u.Address }). + WithName("address"). + Required(), + ) + teacherValidation := validation.New[Teacher]( + validation.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Rules(validation.EqualTo("Tom")), + validation.For(func(t Teacher) University { return t.University }). + WithName("university"). + Include(universityValidation), + ).WithName("Teacher") + + teacher := Teacher{ + Name: "Jerry", + Age: 51 * year, + University: University{ + Name: "Poznan University of Technology", + Address: "", + }, + } + + err := teacherValidation.Validate(teacher) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation for Teacher has failed for the following properties: + // - 'name' with value 'Jerry': + // - should be equal to 'Tom' + // - 'university.address': + // - property is required but was empty +} + +// When dealing with slices we often want to both validate the whole slice +// and each of its elements. +// You can use [ForSlice] function to do just that. +// It returns a new struct [PropertyRulesForSlice] which behaves exactly +// the same as [PropertyRules], but extends its API slightly. +// +// To define rules for each element use: +// - [PropertyRulesForSlice.RulesForEach] +// - [PropertyRulesForSlice.IncludeForEach] +// These work exactly the same way as [PropertyRules.Rules] and [PropertyRules.Include] +// verifying each slice element. +// +// [PropertyRulesForSlice.Rules] is in turn used to define rules for the whole slice. +// +// NOTE: [PropertyRulesForSlice] does not implement Include function for the whole slice. +// +// In the below example, we're defining that students slice must have at most 2 elements +// and that each element's index must be unique. +// For each element we're also including [Student] [Validator]. +// Notice that property path for slices has the following format: +// []. +func ExampleForSlice() { + studentValidator := validation.New[Student]( + validation.For(func(s Student) string { return s.Index }). + WithName("index"). + Rules(validation.StringLength(9, 9)), + ) + teacherValidator := validation.New[Teacher]( + validation.ForSlice(func(t Teacher) []Student { return t.Students }). + WithName("students"). + Rules( + validation.SliceMaxLength[[]Student](2), + validation.SliceUnique(func(v Student) string { return v.Index })). + IncludeForEach(studentValidator), + ).When(func(t Teacher) bool { return t.Age < 50 }) + + teacher := Teacher{ + Name: "John", + Students: []Student{ + {Index: "918230014"}, + {Index: "9182300123"}, + {Index: "918230014"}, + }, + } + + err := teacherValidator.Validate(teacher) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation has failed for the following properties: + // - 'students' with value '[{"index":"918230014"},{"index":"9182300123"},{"index":"918230014"}]': + // - length must be less than or equal to 2 + // - elements are not unique, index 0 collides with index 2 + // - 'students[1].index' with value '9182300123': + // - length must be between 9 and 9 +} + +// When dealing with maps there are three forms of iteration: +// - keys +// - values +// - key-value pairs (items) +// +// You can use [ForMap] function to define rules for all the aforementioned iterators. +// It returns a new struct [PropertyRulesForMap] which behaves similar to +// [PropertyRulesForSlice].. +// +// To define rules for keys use: +// - [PropertyRulesForMap.RulesForKeys] +// - [PropertyRulesForMap.IncludeForKeys] +// - [PropertyRulesForMap.RulesForValues] +// - [PropertyRulesForMap.IncludeForValues] +// - [PropertyRulesForMap.RulesForItems] +// - [PropertyRulesForMap.IncludeForItems] +// These work exactly the same way as [PropertyRules.Rules] and [PropertyRules.Include] +// verifying each map's key, value or [MapItem]. +// +// [PropertyRulesForMap.Rules] is in turn used to define rules for the whole map. +// +// NOTE: [PropertyRulesForMap] does not implement Include function for the whole map. +// +// In the below example, we're defining that student index to [Teacher] map: +// - Must have at most 2 elements (map). +// - Keys must have a length of 9 (keys). +// - Eve cannot be a teacher for any student (values). +// - Joan cannot be a teacher for student with index 918230013 (items). +// +// Notice that property path for maps has the following format: +// .. +func ExampleForMap() { + teacherValidator := validation.New[Teacher]( + validation.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Rules(validation.NotEqualTo("Eve")), + ) + tutoringValidator := validation.New[Tutoring]( + validation.ForMap(func(t Tutoring) map[string]Teacher { return t.StudentIndexToTeacher }). + WithName("students"). + Rules( + validation.MapMaxLength[map[string]Teacher](2), + ). + RulesForKeys( + validation.StringLength(9, 9), + ). + IncludeForValues(teacherValidator). + RulesForItems(validation.NewSingleRule(func(v validation.MapItem[string, Teacher]) error { + if v.Key == "918230013" && v.Value.Name == "Joan" { + return validation.NewRuleError( + "Joan cannot be a teacher for student with index 918230013", + "joan_teacher", + ) + } + return nil + })), + ) + + tutoring := Tutoring{ + StudentIndexToTeacher: map[string]Teacher{ + "918230013": {Name: "Joan"}, + "9182300123": {Name: "Eve"}, + "918230014": {Name: "Joan"}, + }, + } + + err := tutoringValidator.Validate(tutoring) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation has failed for the following properties: + // - 'students' with value '{"9182300123":{"name":"Eve","age":0,"students":null,"university":{"name":"","address":""}},"91823001...': + // - length must be less than or equal to 2 + // - 'students.9182300123' with key '9182300123': + // - length must be between 9 and 9 + // - 'students.9182300123.name' with value 'Eve': + // - should be not equal to 'Eve' + // - 'students.918230013' with value '{"name":"Joan","age":0,"students":null,"university":{"name":"","address":""}}': + // - Joan cannot be a teacher for student with index 918230013 +} + +// To only run property validation on condition, use [PropertyRules.When]. +// Predicates set through [PropertyRules.When] are evaluated in the order they are provided. +// If any predicate is not met, validation rules are not evaluated for the whole [PropertyRules]. +// +// It's recommended to define [PropertyRules.When] before [PropertyRules.Rules] declaration. +func ExamplePropertyRules_When() { + v := validation.New[Teacher]( + validation.For(func(t Teacher) string { return t.Name }). + WithName("name"). + When(func(t Teacher) bool { return t.Name == "Jerry" }). + Rules(validation.NotEqualTo("Jerry")), + ).WithName("Teacher") + + for _, name := range []string{"Tom", "Jerry", "Mickey"} { + teacher := Teacher{Name: name} + err := v.Validate(teacher) + if err != nil { + fmt.Println(err) + } + } + + // Output: + // Validation for Teacher has failed for the following properties: + // - 'name' with value 'Jerry': + // - should be not equal to 'Jerry' +} + +// To customize how [Rule] are evaluated use [PropertyRules.Cascade]. +// Use [CascadeModeStop] to stop validation after the first error. +// If you wish to revert to the default behavior, use [CascadeModeContinue]. +func ExamplePropertyRules_Cascade() { + alwaysFailingRule := validation.NewSingleRule(func(string) error { + return fmt.Errorf("always fails") + }) + + v := validation.New[Teacher]( + validation.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Cascade(validation.CascadeModeStop). + Rules(validation.NotEqualTo("Jerry")). + Rules(alwaysFailingRule), + ).WithName("Teacher") + + for _, name := range []string{"Tom", "Jerry"} { + teacher := Teacher{Name: name} + err := v.Validate(teacher) + if err != nil { + fmt.Println(err) + } + } + + // Output: + // Validation for Teacher has failed for the following properties: + // - 'name' with value 'Tom': + // - always fails + // Validation for Teacher has failed for the following properties: + // - 'name' with value 'Jerry': + // - should be not equal to 'Jerry' +} + +// Bringing it all (mostly) together, let's create a fully fledged [Validator] for [Teacher]. +func ExampleValidator() { + universityValidation := validation.New[University]( + validation.For(func(u University) string { return u.Address }). + WithName("address"). + Required(), + ) + studentValidator := validation.New[Student]( + validation.For(func(s Student) string { return s.Index }). + WithName("index"). + Rules(validation.StringLength(9, 9)), + ) + teacherValidator := validation.New[Teacher]( + validation.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Required(). + Rules( + validation.StringNotEmpty(), + validation.OneOf("Jake", "George")), + validation.ForSlice(func(t Teacher) []Student { return t.Students }). + WithName("students"). + Rules( + validation.SliceMaxLength[[]Student](2), + validation.SliceUnique(func(v Student) string { return v.Index })). + IncludeForEach(studentValidator), + validation.For(func(t Teacher) University { return t.University }). + WithName("university"). + Include(universityValidation), + ).When(func(t Teacher) bool { return t.Age < 50 }) + + teacher := Teacher{ + Name: "John", + Students: []Student{ + {Index: "918230014"}, + {Index: "9182300123"}, + {Index: "918230014"}, + }, + University: University{ + Name: "Poznan University of Technology", + Address: "", + }, + } + + err := teacherValidator.WithName("John").Validate(teacher) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation for John has failed for the following properties: + // - 'name' with value 'John': + // - must be one of [Jake, George] + // - 'students' with value '[{"index":"918230014"},{"index":"9182300123"},{"index":"918230014"}]': + // - length must be less than or equal to 2 + // - elements are not unique, index 0 collides with index 2 + // - 'students[1].index' with value '9182300123': + // - length must be between 9 and 9 + // - 'university.address': + // - property is required but was empty +} + +// What follows below is a collection of more complex examples and useful patterns. + +// When dealing with properties that should only be validated if a certain other +// property has specific value, it's recommended to use [PropertyRules.When] and [PropertyRules.Include] +// to separate validation paths into non-overlapping branches. +// +// Notice how in the below example [File.Format] is the common, +// shared property between [CSV] and [JSON] files. +// We define separate [Validator] for [CSV] and [JSON] and use [PropertyRules.When] to only validate +// their included [Validator] if the correct [File.Format] is provided. +func ExampleValidator_branchingPattern() { + type ( + CSV struct { + Separator string `json:"separator"` + } + JSON struct { + Indent string `json:"indent"` + } + File struct { + Format string `json:"format"` + CSV *CSV `json:"csv,omitempty"` + JSON *JSON `json:"json,omitempty"` + } + ) + + csvValidation := validation.New[CSV]( + validation.For(func(c CSV) string { return c.Separator }). + WithName("separator"). + Required(). + Rules(validation.OneOf(",", ";")), + ) + + jsonValidation := validation.New[JSON]( + validation.For(func(j JSON) string { return j.Indent }). + WithName("indent"). + Required(). + Rules(validation.StringMatchRegexp(regexp.MustCompile(`^\s*$`))), + ) + + fileValidation := validation.New[File]( + validation.ForPointer(func(f File) *CSV { return f.CSV }). + When(func(f File) bool { return f.Format == "csv" }). + Include(csvValidation), + validation.ForPointer(func(f File) *JSON { return f.JSON }). + When(func(f File) bool { return f.Format == "json" }). + Include(jsonValidation), + validation.For(func(f File) string { return f.Format }). + WithName("format"). + Required(). + Rules(validation.OneOf("csv", "json")), + ).WithName("File") + + file := File{ + Format: "json", + CSV: nil, + JSON: &JSON{ + Indent: "invalid", + }, + } + + err := fileValidation.Validate(file) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation for File has failed for the following properties: + // - 'indent' with value 'invalid': + // - string must match regular expression: '^\s*$' +} + +// When documenting an API it's often a struggle to keep consistency +// between the code and documentation we write for it. +// What If your code could be self-descriptive? +// Specifically, what If we could generate documentation out of our validation rules? +// We can achieve that by using [Plan] function! +// +// There are multiple ways to improve the generated documentation: +// - Use [PropertyRules.WithExamples] to provide a list of example values for the property. +// - Use [SingleRule.WithDescription] to provide a plan-only description for your rule. +// For builtin rules, the description is already provided. +// - Use [WhenDescription] to provide a plan-only description for your when conditions. +func ExamplePlan() { + v := validation.New[Teacher]( + validation.For(func(t Teacher) string { return t.Name }). + WithName("name"). + WithExamples("Jake", "John"). + When( + func(t Teacher) bool { return t.Name == "Jerry" }, + validation.WhenDescription("name is Jerry"), + ). + Rules( + validation.NotEqualTo("Jerry"). + WithDetails("Jerry is just a name!"), + ), + ) + + properties := validation.Plan(v) + _ = yaml.NewEncoder(os.Stdout, yaml.Indent(2)).Encode(properties) + + // Output: + // - path: $.name + // type: string + // examples: + // - Jake + // - John + // rules: + // - description: should be not equal to 'Jerry' + // details: Jerry is just a name! + // errorCode: not_equal_to + // conditions: + // - name is Jerry +} diff --git a/pkg/govy/forbidden.go b/pkg/govy/forbidden.go new file mode 100644 index 0000000..e821f62 --- /dev/null +++ b/pkg/govy/forbidden.go @@ -0,0 +1,15 @@ +package validation + +import "github.com/pkg/errors" + +func Forbidden[T any]() SingleRule[T] { + msg := "property is forbidden" + return NewSingleRule(func(v T) error { + if isEmptyFunc(v) { + return nil + } + return errors.New(msg) + }). + WithErrorCode(ErrorCodeForbidden). + WithDescription(msg) +} diff --git a/pkg/govy/forbidden_test.go b/pkg/govy/forbidden_test.go new file mode 100644 index 0000000..4e11c78 --- /dev/null +++ b/pkg/govy/forbidden_test.go @@ -0,0 +1,21 @@ +package validation + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestForbidden(t *testing.T) { + t.Run("passes", func(t *testing.T) { + err := Forbidden[string]().Validate("") + assert.NoError(t, err) + }) + t.Run("fails", func(t *testing.T) { + err := Forbidden[string]().Validate("test") + require.Error(t, err) + assert.EqualError(t, err, "property is forbidden") + assert.True(t, HasErrorCode(err, ErrorCodeForbidden)) + }) +} diff --git a/pkg/govy/length.go b/pkg/govy/length.go new file mode 100644 index 0000000..97e02f2 --- /dev/null +++ b/pkg/govy/length.go @@ -0,0 +1,125 @@ +package validation + +import ( + "fmt" + "unicode/utf8" + + "github.com/pkg/errors" +) + +func StringLength(lower, upper int) SingleRule[string] { + msg := fmt.Sprintf("length must be between %d and %d", lower, upper) + return NewSingleRule(func(v string) error { + length := utf8.RuneCountInString(v) + if length < lower || length > upper { + return errors.New(msg) + } + return nil + }). + WithErrorCode(ErrorCodeStringLength). + WithDescription(msg) +} + +func StringMinLength(limit int) SingleRule[string] { + msg := fmt.Sprintf("length must be %s %d", cmpGreaterThanOrEqual, limit) + return NewSingleRule(func(v string) error { + length := utf8.RuneCountInString(v) + if length < limit { + return errors.New(msg) + } + return nil + }). + WithErrorCode(ErrorCodeStringMinLength). + WithDescription(msg) +} + +func StringMaxLength(limit int) SingleRule[string] { + msg := fmt.Sprintf("length must be %s %d", cmpLessThanOrEqual, limit) + return NewSingleRule(func(v string) error { + length := utf8.RuneCountInString(v) + if length > limit { + return errors.New(msg) + } + return nil + }). + WithErrorCode(ErrorCodeStringMaxLength). + WithDescription(msg) +} + +func SliceLength[S ~[]E, E any](lower, upper int) SingleRule[S] { + msg := fmt.Sprintf("length must be between %d and %d", lower, upper) + return NewSingleRule(func(v S) error { + length := len(v) + if length < lower || length > upper { + return errors.New(msg) + } + return nil + }). + WithErrorCode(ErrorCodeSliceLength). + WithDescription(msg) +} + +func SliceMinLength[S ~[]E, E any](limit int) SingleRule[S] { + msg := fmt.Sprintf("length must be %s %d", cmpGreaterThanOrEqual, limit) + return NewSingleRule(func(v S) error { + length := len(v) + if length < limit { + return errors.New(msg) + } + return nil + }). + WithErrorCode(ErrorCodeSliceMinLength). + WithDescription(msg) +} + +func SliceMaxLength[S ~[]E, E any](limit int) SingleRule[S] { + msg := fmt.Sprintf("length must be %s %d", cmpLessThanOrEqual, limit) + return NewSingleRule(func(v S) error { + length := len(v) + if length > limit { + return errors.New(msg) + } + return nil + }). + WithErrorCode(ErrorCodeSliceMaxLength). + WithDescription(msg) +} + +func MapLength[M ~map[K]V, K comparable, V any](lower, upper int) SingleRule[M] { + msg := fmt.Sprintf("length must be between %d and %d", lower, upper) + return NewSingleRule(func(v M) error { + length := len(v) + if length < lower || length > upper { + return errors.New(msg) + } + return nil + }). + WithErrorCode(ErrorCodeMapLength). + WithDescription(msg) +} + +func MapMinLength[M ~map[K]V, K comparable, V any](limit int) SingleRule[M] { + msg := fmt.Sprintf("length must be %s %d", cmpGreaterThanOrEqual, limit) + return NewSingleRule(func(v M) error { + length := len(v) + if length < limit { + return errors.New(msg) + } + return nil + }). + WithErrorCode(ErrorCodeMapMinLength). + WithDescription(msg) +} + +func MapMaxLength[M ~map[K]V, K comparable, V any](limit int) SingleRule[M] { + msg := fmt.Sprintf("length must be %s %d", cmpLessThanOrEqual, limit) + return NewSingleRule(func(v M) error { + length := len(v) + if length > limit { + return errors.New(msg) + } + return nil + }). + WithErrorCode(ErrorCodeMapMaxLength). + WithDescription(msg) +} diff --git a/pkg/govy/length_test.go b/pkg/govy/length_test.go new file mode 100644 index 0000000..6212efc --- /dev/null +++ b/pkg/govy/length_test.go @@ -0,0 +1,141 @@ +package validation + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStringLength(t *testing.T) { + t.Run("passes", func(t *testing.T) { + err := StringLength(0, 4).Validate("test") + assert.NoError(t, err) + }) + t.Run("fails", func(t *testing.T) { + for min, max := range map[int]int{ + 0: 2, + 10: 20, + } { + err := StringLength(min, max).Validate("test") + require.Error(t, err) + assert.EqualError(t, err, fmt.Sprintf("length must be between %d and %d", min, max)) + assert.True(t, HasErrorCode(err, ErrorCodeStringLength)) + } + }) +} + +func TestStringMinLength(t *testing.T) { + t.Run("passes", func(t *testing.T) { + err := StringMinLength(0).Validate("test") + assert.NoError(t, err) + }) + t.Run("fails", func(t *testing.T) { + err := StringMinLength(5).Validate("test") + require.Error(t, err) + assert.EqualError(t, err, "length must be greater than or equal to 5") + assert.True(t, HasErrorCode(err, ErrorCodeStringMinLength)) + }) +} + +func TestStringMaxLength(t *testing.T) { + t.Run("passes", func(t *testing.T) { + err := StringMaxLength(4).Validate("test") + assert.NoError(t, err) + }) + t.Run("fails", func(t *testing.T) { + err := StringMaxLength(3).Validate("test") + require.Error(t, err) + assert.EqualError(t, err, "length must be less than or equal to 3") + assert.True(t, HasErrorCode(err, ErrorCodeStringMaxLength)) + }) +} + +func TestSliceLength(t *testing.T) { + t.Run("passes", func(t *testing.T) { + err := SliceLength[[]string](0, 1).Validate([]string{"test"}) + assert.NoError(t, err) + }) + t.Run("fails", func(t *testing.T) { + for min, max := range map[int]int{ + 0: 1, + 3: 10, + } { + err := SliceLength[[]string](min, max).Validate([]string{"test", "test"}) + require.Error(t, err) + assert.EqualError(t, err, fmt.Sprintf("length must be between %d and %d", min, max)) + assert.True(t, HasErrorCode(err, ErrorCodeSliceLength)) + } + }) +} + +func TestSliceMinLength(t *testing.T) { + t.Run("passes", func(t *testing.T) { + err := SliceMinLength[[]string](1).Validate([]string{"test"}) + assert.NoError(t, err) + }) + t.Run("fails", func(t *testing.T) { + err := SliceMinLength[[]string](2).Validate([]string{"test"}) + require.Error(t, err) + assert.EqualError(t, err, "length must be greater than or equal to 2") + assert.True(t, HasErrorCode(err, ErrorCodeSliceMinLength)) + }) +} + +func TestSliceMaxLength(t *testing.T) { + t.Run("passes", func(t *testing.T) { + err := SliceMaxLength[[]string](1).Validate([]string{"test"}) + assert.NoError(t, err) + }) + t.Run("fails", func(t *testing.T) { + err := SliceMaxLength[[]string](1).Validate([]string{"1", "2"}) + require.Error(t, err) + assert.EqualError(t, err, "length must be less than or equal to 1") + assert.True(t, HasErrorCode(err, ErrorCodeSliceMaxLength)) + }) +} + +func TestMapLength(t *testing.T) { + t.Run("passes", func(t *testing.T) { + err := MapLength[map[string]string](0, 1).Validate(map[string]string{"this": "that"}) + assert.NoError(t, err) + }) + t.Run("fails", func(t *testing.T) { + for min, max := range map[int]int{ + 0: 1, + 3: 10, + } { + err := MapLength[map[string]string](min, max).Validate(map[string]string{"a": "b", "c": "d"}) + require.Error(t, err) + assert.EqualError(t, err, fmt.Sprintf("length must be between %d and %d", min, max)) + assert.True(t, HasErrorCode(err, ErrorCodeMapLength)) + } + }) +} + +func TestMapMinLength(t *testing.T) { + t.Run("passes", func(t *testing.T) { + err := MapMinLength[map[string]string](1).Validate(map[string]string{"a": "b"}) + assert.NoError(t, err) + }) + t.Run("fails", func(t *testing.T) { + err := MapMinLength[map[string]string](2).Validate(map[string]string{"a": "b"}) + require.Error(t, err) + assert.EqualError(t, err, "length must be greater than or equal to 2") + assert.True(t, HasErrorCode(err, ErrorCodeMapMinLength)) + }) +} + +func TestMapMaxLength(t *testing.T) { + t.Run("passes", func(t *testing.T) { + err := MapMaxLength[map[string]string](1).Validate(map[string]string{"a": "b"}) + assert.NoError(t, err) + }) + t.Run("fails", func(t *testing.T) { + err := MapMaxLength[map[string]string](1).Validate(map[string]string{"a": "b", "c": "d"}) + require.Error(t, err) + assert.EqualError(t, err, "length must be less than or equal to 1") + assert.True(t, HasErrorCode(err, ErrorCodeMapMaxLength)) + }) +} diff --git a/pkg/govy/one_of.go b/pkg/govy/one_of.go new file mode 100644 index 0000000..6a71870 --- /dev/null +++ b/pkg/govy/one_of.go @@ -0,0 +1,75 @@ +package validation + +import ( + "fmt" + "strings" + + "github.com/pkg/errors" + "golang.org/x/exp/maps" + "golang.org/x/exp/slices" +) + +func OneOf[T comparable](values ...T) SingleRule[T] { + return NewSingleRule(func(v T) error { + for i := range values { + if v == values[i] { + return nil + } + } + return errors.New("must be one of " + prettyOneOfList(values)) + }). + WithErrorCode(ErrorCodeOneOf). + WithDescription(func() string { + b := strings.Builder{} + prettyStringListBuilder(&b, values, false) + return "must be one of: " + b.String() + }()) +} + +// MutuallyExclusive checks if properties are mutually exclusive. +// This means, exactly one of the properties can be provided. +// If required is true, then a single non-empty property is required. +func MutuallyExclusive[S any](required bool, getters map[string]func(s S) any) SingleRule[S] { + return NewSingleRule(func(s S) error { + var nonEmpty []string + for name, getter := range getters { + v := getter(s) + if isEmptyFunc(v) { + continue + } + nonEmpty = append(nonEmpty, name) + } + switch len(nonEmpty) { + case 0: + if !required { + return nil + } + keys := maps.Keys(getters) + slices.Sort(keys) + return errors.Errorf( + "one of %s properties must be set, none was provided", + prettyOneOfList(keys)) + case 1: + return nil + default: + slices.Sort(nonEmpty) + return errors.Errorf( + "%s properties are mutually exclusive, provide only one of them", + prettyOneOfList(nonEmpty)) + } + }). + WithErrorCode(ErrorCodeMutuallyExclusive). + WithDescription(func() string { + keys := maps.Keys(getters) + return fmt.Sprintf("properties are mutually exclusive: %s", strings.Join(keys, ", ")) + }()) +} + +func prettyOneOfList[T any](values []T) string { + b := strings.Builder{} + b.Grow(2 + len(values)) + b.WriteString("[") + prettyStringListBuilder(&b, values, false) + b.WriteString("]") + return b.String() +} diff --git a/pkg/govy/one_of_test.go b/pkg/govy/one_of_test.go new file mode 100644 index 0000000..d346c61 --- /dev/null +++ b/pkg/govy/one_of_test.go @@ -0,0 +1,82 @@ +package validation + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOneOf(t *testing.T) { + t.Run("passes", func(t *testing.T) { + err := OneOf("this", "that").Validate("that") + assert.NoError(t, err) + }) + t.Run("fails", func(t *testing.T) { + err := OneOf("this", "that").Validate("those") + require.Error(t, err) + assert.EqualError(t, err, "must be one of [this, that]") + assert.True(t, HasErrorCode(err, ErrorCodeOneOf)) + }) +} + +func TestMutuallyExclusive(t *testing.T) { + type PaymentMethod struct { + Cash *string + Card *string + Transfer *string + } + getters := map[string]func(p PaymentMethod) any{ + "Cash": func(p PaymentMethod) any { return p.Cash }, + "Card": func(p PaymentMethod) any { return p.Card }, + "Transfer": func(p PaymentMethod) any { return p.Transfer }, + } + + t.Run("passes with required", func(t *testing.T) { + err := MutuallyExclusive(true, getters).Validate(PaymentMethod{ + Cash: nil, + Card: ptr("2$"), + Transfer: nil, + }) + assert.NoError(t, err) + }) + t.Run("passes with non-required", func(t *testing.T) { + err := MutuallyExclusive(false, getters).Validate(PaymentMethod{ + Cash: nil, + Card: nil, + Transfer: nil, + }) + assert.NoError(t, err) + }) + t.Run("fails", func(t *testing.T) { + for _, required := range []bool{true, false} { + err := MutuallyExclusive(required, getters).Validate(PaymentMethod{ + Cash: nil, + Card: ptr("2$"), + Transfer: ptr("2$"), + }) + assert.EqualError(t, err, "[Card, Transfer] properties are mutually exclusive, provide only one of them") + assert.True(t, HasErrorCode(err, ErrorCodeMutuallyExclusive)) + } + }) + t.Run("fails, multiple conflicts", func(t *testing.T) { + for _, required := range []bool{true, false} { + err := MutuallyExclusive(required, getters).Validate(PaymentMethod{ + Cash: ptr("2$"), + Card: ptr("2$"), + Transfer: ptr("2$"), + }) + assert.EqualError(t, err, "[Card, Cash, Transfer] properties are mutually exclusive, provide only one of them") + assert.True(t, HasErrorCode(err, ErrorCodeMutuallyExclusive)) + } + }) + t.Run("required fails", func(t *testing.T) { + err := MutuallyExclusive(true, getters).Validate(PaymentMethod{ + Cash: nil, + Card: nil, + Transfer: nil, + }) + assert.EqualError(t, err, "one of [Card, Cash, Transfer] properties must be set, none was provided") + assert.True(t, HasErrorCode(err, ErrorCodeMutuallyExclusive)) + }) +} diff --git a/pkg/govy/plan.go b/pkg/govy/plan.go new file mode 100644 index 0000000..feebe0b --- /dev/null +++ b/pkg/govy/plan.go @@ -0,0 +1,137 @@ +package validation + +import ( + "reflect" + "sort" + "strings" + + "golang.org/x/exp/maps" +) + +// PropertyPlan is a validation plan for a single property. +type PropertyPlan struct { + Path string `json:"path"` + Type string `json:"type"` + Package string `json:"package,omitempty"` + IsOptional bool `json:"isOptional,omitempty"` + IsHidden bool `json:"isHidden,omitempty"` + Examples []string `json:"examples,omitempty"` + Rules []RulePlan `json:"rules,omitempty"` +} + +// RulePlan is a validation plan for a single rule. +type RulePlan struct { + Description string `json:"description"` + Details string `json:"details,omitempty"` + ErrorCode ErrorCode `json:"errorCode,omitempty"` + Conditions []string `json:"conditions,omitempty"` +} + +func (r RulePlan) isEmpty() bool { + return r.Description == "" && r.Details == "" && r.ErrorCode == "" && len(r.Conditions) == 0 +} + +// Plan creates a validation plan for the provided [Validator]. +// Each property is represented by a [PropertyPlan] which aggregates its every [RulePlan]. +// If a property does not have any rules, it won't be included in the result. +func Plan[S any](v Validator[S]) []PropertyPlan { + all := make([]planBuilder, 0) + v.plan(planBuilder{path: "$", all: &all}) + propertiesMap := make(map[string]PropertyPlan) + for _, p := range all { + entry, ok := propertiesMap[p.path] + if ok { + entry.Rules = append(entry.Rules, p.rulePlan) + propertiesMap[p.path] = entry + } else { + entry = PropertyPlan{ + Path: p.path, + Type: p.propertyPlan.Type, + Package: p.propertyPlan.Package, + Examples: p.propertyPlan.Examples, + IsOptional: p.propertyPlan.IsOptional, + IsHidden: p.propertyPlan.IsHidden, + } + if !p.rulePlan.isEmpty() { + entry.Rules = append(entry.Rules, p.rulePlan) + } + propertiesMap[p.path] = entry + } + } + properties := maps.Values(propertiesMap) + sort.Slice(properties, func(i, j int) bool { return properties[i].Path < properties[j].Path }) + return properties +} + +// planner is an interface for types that can create a [PropertyPlan] or [RulePlan]. +type planner interface { + plan(builder planBuilder) +} + +// planBuilder is used to traverse the validation rules and build a slice of [PropertyPlan]. +type planBuilder struct { + path string + rulePlan RulePlan + propertyPlan PropertyPlan + // all stores every rule for the current property. + // It's not safe for concurrent usage. + all *[]planBuilder +} + +func (p planBuilder) appendPath(path string) planBuilder { + builder := planBuilder{ + all: p.all, + rulePlan: p.rulePlan, + propertyPlan: p.propertyPlan, + } + switch { + case p.path == "" && path != "": + builder.path = path + case p.path != "" && path != "": + if strings.HasPrefix(path, "[") { + builder.path = p.path + path + } else { + builder.path = p.path + "." + path + } + default: + builder.path = p.path + } + return builder +} + +func (p planBuilder) setExamples(examples ...string) planBuilder { + p.propertyPlan.Examples = examples + return p +} + +// typeInfo stores the type name and its package if it's available. +type typeInfo struct { + Name string + Package string +} + +// getTypeInfo returns the information for the type T. +// It returns the type name without package path or name. +// It strips the pointer '*' from the type name. +// Package is only available if the type is not a built-in type. +func getTypeInfo[T any]() typeInfo { + typ := reflect.TypeOf(*new(T)) + if typ == nil { + return typeInfo{} + } + var result typeInfo + if typ.Kind() == reflect.Ptr { + typ = typ.Elem() + } + if typ.Kind() == reflect.Slice { + typ = typ.Elem() + result.Name = "[]" + } + if typ.PkgPath() == "" { + result.Name += typ.String() + } else { + result.Name += typ.Name() + result.Package = typ.PkgPath() + } + return result +} diff --git a/pkg/govy/plan_test.go b/pkg/govy/plan_test.go new file mode 100644 index 0000000..0c5d8d0 --- /dev/null +++ b/pkg/govy/plan_test.go @@ -0,0 +1,142 @@ +package validation + +import ( + "bytes" + _ "embed" + "encoding/json" + "testing" + + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +//go:embed test_data/expected_pod_plan.json +var expectedPlanJSON string + +type Pod struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Metadata PodMetadata `json:"metadata"` + Spec PodSpec `json:"spec"` + Status *PodStatus `json:"status,omitempty"` +} + +type PodMetadata struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + Labels Labels `json:"labels"` + Annotations Annotations `json:"annotations"` +} + +type Labels map[string]string + +type Annotations map[string]string + +type PodSpec struct { + DNSPolicy string `json:"dnsPolicy"` + Containers []Container `json:"containers"` +} + +type Container struct { + Name string `json:"name"` + Image string `json:"image"` + Env []EnvVar `json:"env"` +} + +type EnvVar struct { + Name string `json:"name"` + Value string `json:"value"` +} + +type PodStatus struct { + HostIP string `json:"hostIP"` +} + +func TestPlan(t *testing.T) { + metadataValidator := New[PodMetadata]( + For(func(p PodMetadata) string { return p.Name }). + WithName("name"). + Required(). + Rules(StringNotEmpty()), + For(func(p PodMetadata) string { return p.Namespace }). + WithName("namespace"). + Required(). + Rules(StringNotEmpty()), + ForMap(func(p PodMetadata) Labels { return p.Labels }). + WithName("labels"). + Rules(MapMaxLength[Labels](10)). + RulesForKeys(StringIsDNSSubdomain()). + RulesForValues(StringMaxLength(120)), + ForMap(func(p PodMetadata) Annotations { return p.Annotations }). + WithName("annotations"). + Rules(MapMaxLength[Annotations](10)). + RulesForItems( + NewSingleRule(func(a MapItem[string, string]) error { + if a.Key == a.Value { + return errors.New("key and value must not be equal") + } + return nil + }).WithDescription("key and value must not be equal"), + ), + ) + + specValidator := New[PodSpec]( + For(func(p PodSpec) string { return p.DNSPolicy }). + WithName("dnsPolicy"). + Required(). + Rules(OneOf("ClusterFirst", "Default")), + ForSlice(func(p PodSpec) []Container { return p.Containers }). + WithName("containers"). + Rules( + SliceMaxLength[[]Container](10), + SliceUnique(func(c Container) string { return c.Name }), + ). + IncludeForEach(New[Container]( + For(func(c Container) string { return c.Name }). + WithName("name"). + Required(). + Rules(StringIsDNSSubdomain()), + For(func(c Container) string { return c.Image }). + WithName("image"). + Required(). + Rules(StringNotEmpty()), + ForSlice(func(c Container) []EnvVar { return c.Env }). + WithName("env"). + RulesForEach( + NewSingleRule(func(e EnvVar) error { + return nil + }).WithDescription("custom error!"), + ), + )), + ) + + validator := New[Pod]( + For(func(p Pod) string { return p.APIVersion }). + WithName("apiVersion"). + Required(). + Rules(OneOf("v1", "v2")), + For(func(p Pod) string { return p.Kind }). + WithName("kind"). + Required(). + Rules(EqualTo("Pod")), + For(func(p Pod) PodMetadata { return p.Metadata }). + WithName("metadata"). + Required(). + Include(metadataValidator), + For(func(p Pod) PodSpec { return p.Spec }). + WithName("spec"). + Required(). + Include(specValidator), + ) + + properties := Plan(validator) + + buf := bytes.Buffer{} + enc := json.NewEncoder(&buf) + enc.SetIndent("", " ") + err := enc.Encode(properties) + require.NoError(t, err) + + assert.Equal(t, expectedPlanJSON, buf.String()) +} diff --git a/pkg/govy/predicate.go b/pkg/govy/predicate.go new file mode 100644 index 0000000..7c15ff0 --- /dev/null +++ b/pkg/govy/predicate.go @@ -0,0 +1,44 @@ +package validation + +import "fmt" + +// WhenOptions defines optional parameters for the When conditions. +type WhenOptions struct { + description string +} + +// WhenDescription sets the description for the When condition. +func WhenDescription(format string, a ...interface{}) WhenOptions { + return WhenOptions{description: fmt.Sprintf(format, a...)} +} + +type Predicate[S any] func(S) bool + +type predicateContainer[S any] struct { + predicate Predicate[S] + description string +} + +type predicateMatcher[S any] struct { + predicates []predicateContainer[S] +} + +func (p predicateMatcher[S]) when(predicate Predicate[S], opts ...WhenOptions) predicateMatcher[S] { + container := predicateContainer[S]{predicate: predicate} + for _, opt := range opts { + if opt.description != "" { + container.description = opt.description + } + } + p.predicates = append(p.predicates, container) + return p +} + +func (p predicateMatcher[S]) matchPredicates(st S) bool { + for _, predicate := range p.predicates { + if !predicate.predicate(st) { + return false + } + } + return true +} diff --git a/pkg/govy/required.go b/pkg/govy/required.go new file mode 100644 index 0000000..eaa20b2 --- /dev/null +++ b/pkg/govy/required.go @@ -0,0 +1,23 @@ +package validation + +import ( + "reflect" +) + +func Required[T any]() SingleRule[T] { + return NewSingleRule(func(v T) error { + if isEmptyFunc(v) { + return NewRequiredError() + } + return nil + }). + WithErrorCode(ErrorCodeRequired). + WithDescription("property is required") +} + +// isEmptyFunc checks only the types which it makes sense for. +// It's hard to consider 0 an empty value for anything really. +func isEmptyFunc(v interface{}) bool { + rv := reflect.ValueOf(v) + return rv.Kind() == 0 || rv.IsZero() +} diff --git a/pkg/govy/required_test.go b/pkg/govy/required_test.go new file mode 100644 index 0000000..921043e --- /dev/null +++ b/pkg/govy/required_test.go @@ -0,0 +1,37 @@ +package validation + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRequired(t *testing.T) { + t.Run("passes", func(t *testing.T) { + for _, v := range []interface{}{ + 1, + "s", + 0.1, + []int{}, + map[string]int{}, + } { + err := Required[any]().Validate(v) + assert.NoError(t, err) + } + }) + t.Run("fails", func(t *testing.T) { + for _, v := range []interface{}{ + nil, + struct{}{}, + "", + false, + 0, + 0.0, + } { + err := Required[any]().Validate(v) + require.Error(t, err) + assert.True(t, HasErrorCode(err, ErrorCodeRequired)) + } + }) +} diff --git a/pkg/govy/rule.go b/pkg/govy/rule.go new file mode 100644 index 0000000..153105b --- /dev/null +++ b/pkg/govy/rule.go @@ -0,0 +1,177 @@ +package validation + +import ( + "fmt" +) + +// Rule is the interface for all validation rules. +type Rule[T any] interface { + Validate(v T) error +} + +// NewSingleRule creates a new [SingleRule] instance. +func NewSingleRule[T any](validate func(v T) error) SingleRule[T] { + return SingleRule[T]{validate: validate} +} + +// SingleRule is the basic validation building block. +// It evaluates the provided validation function and enhances it +// with optional [ErrorCode] and arbitrary details. +type SingleRule[T any] struct { + validate func(v T) error + errorCode ErrorCode + details string + message string + description string +} + +// Validate runs validation function on the provided value. +// It can handle different types of errors returned by the function: +// - *[RuleError], which details and [ErrorCode] are optionally extended with the ones defined by [SingleRule]. +// - *[PropertyError], for each of its errors their [ErrorCode] is extended with the one defined by [SingleRule]. +// +// By default, it will construct a new RuleError. +func (r SingleRule[T]) Validate(v T) error { + if err := r.validate(v); err != nil { + switch ev := err.(type) { + case *RuleError: + if len(r.message) > 0 { + ev.Message = r.message + } + ev.Message = addDetailsToMessage(ev.Message, r.details) + return ev.AddCode(r.errorCode) + case *PropertyError: + for _, e := range ev.Errors { + _ = e.AddCode(r.errorCode) + } + return ev + default: + msg := ev.Error() + if len(r.message) > 0 { + msg = r.message + } + return &RuleError{ + Message: addDetailsToMessage(msg, r.details), + Code: r.errorCode, + } + } + } + return nil +} + +// WithErrorCode sets the error code for the returned [RuleError]. +func (r SingleRule[T]) WithErrorCode(code ErrorCode) SingleRule[T] { + r.errorCode = code + return r +} + +// WithMessage overrides the returned [RuleError] error message with message. +func (r SingleRule[T]) WithMessage(format string, a ...any) SingleRule[T] { + if len(a) == 0 { + r.message = format + } else { + r.message = fmt.Sprintf(format, a...) + } + return r +} + +// WithDetails adds details to the returned [RuleError] error message. +func (r SingleRule[T]) WithDetails(format string, a ...any) SingleRule[T] { + if len(a) == 0 { + r.details = format + } else { + r.details = fmt.Sprintf(format, a...) + } + return r +} + +func (r SingleRule[T]) WithDescription(description string) SingleRule[T] { + r.description = description + return r +} + +func (r SingleRule[T]) plan(builder planBuilder) { + builder.rulePlan = RulePlan{ + ErrorCode: r.errorCode, + Details: r.details, + Description: r.description, + Conditions: builder.rulePlan.Conditions, + } + *builder.all = append(*builder.all, builder) +} + +// NewRuleSet creates a new [RuleSet] instance. +func NewRuleSet[T any](rules ...Rule[T]) RuleSet[T] { + return RuleSet[T]{rules: rules} +} + +// RuleSet allows defining [Rule] which aggregates multiple sub-rules. +type RuleSet[T any] struct { + rules []Rule[T] + errorCode ErrorCode + details string +} + +// Validate works the same way as [SingleRule.Validate], +// except each aggregated rule is validated individually. +// The errors are aggregated and returned as a single error which serves as a container for them. +func (r RuleSet[T]) Validate(v T) error { + var errs ruleSetError + for i := range r.rules { + if err := r.rules[i].Validate(v); err != nil { + switch ev := err.(type) { + case *RuleError: + ev.Message = addDetailsToMessage(ev.Message, r.details) + errs = append(errs, ev.AddCode(r.errorCode)) + case *PropertyError: + for _, e := range ev.Errors { + _ = e.AddCode(r.errorCode) + } + errs = append(errs, ev) + default: + errs = append(errs, &RuleError{ + Message: addDetailsToMessage(ev.Error(), r.details), + Code: r.errorCode, + }) + } + } + } + if len(errs) > 0 { + return errs + } + return nil +} + +// WithErrorCode sets the error code for each returned [RuleError]. +func (r RuleSet[T]) WithErrorCode(code ErrorCode) RuleSet[T] { + r.errorCode = code + return r +} + +// WithDetails adds details to each returned [RuleError] error message. +func (r RuleSet[T]) WithDetails(format string, a ...any) RuleSet[T] { + if len(a) == 0 { + r.details = format + } else { + r.details = fmt.Sprintf(format, a...) + } + return r +} + +func (r RuleSet[T]) plan(builder planBuilder) { + for _, rule := range r.rules { + if p, ok := rule.(planner); ok { + p.plan(builder) + } + } +} + +func addDetailsToMessage(msg, details string) string { + if details == "" { + return msg + } + if msg == "" { + return details + } + return msg + "; " + details +} diff --git a/pkg/govy/rule_test.go b/pkg/govy/rule_test.go new file mode 100644 index 0000000..e78b31e --- /dev/null +++ b/pkg/govy/rule_test.go @@ -0,0 +1,120 @@ +package validation + +import ( + "testing" + + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" +) + +func TestSingleRule(t *testing.T) { + r := NewSingleRule(func(v int) error { + if v < 0 { + return errors.Errorf("must be positive") + } + return nil + }) + + err := r.Validate(0) + assert.Nil(t, err) + err = r.Validate(-1) + assert.EqualError(t, err, "must be positive") +} + +func TestSingleRule_WithErrorCode(t *testing.T) { + r := NewSingleRule(func(v int) error { + if v < 0 { + return errors.Errorf("must be positive") + } + return nil + }).WithErrorCode("test") + + err := r.Validate(0) + assert.Nil(t, err) + err = r.Validate(-1) + assert.EqualError(t, err, "must be positive") + assert.Equal(t, "test", err.(*RuleError).Code) +} + +func TestSingleRule_WithMessage(t *testing.T) { + for _, test := range []struct { + Error string + Message string + Details string + ExpectedError string + }{ + { + Error: "this is error", + Message: "", + Details: "details", + ExpectedError: "this is error; details", + }, + { + Error: "this is error", + Message: "this is message", + Details: "", + ExpectedError: "this is message", + }, + { + Error: "", + Message: "message", + Details: "details", + ExpectedError: "message; details", + }, + } { + r := NewSingleRule(func(v int) error { + if v < 0 { + return errors.Errorf(test.Error) + } + return nil + }). + WithErrorCode("test"). + WithMessage(test.Message). + WithDetails(test.Details) + + err := r.Validate(0) + assert.Nil(t, err) + err = r.Validate(-1) + assert.EqualError(t, err, test.ExpectedError) + assert.Equal(t, "test", err.(*RuleError).Code) + } +} + +func TestSingleRule_WithDetails(t *testing.T) { + for _, test := range []struct { + Error string + Details string + ExpectedError string + }{ + { + Error: "this is error", + Details: "details", + ExpectedError: "this is error; details", + }, + { + Error: "this is error", + Details: "", + ExpectedError: "this is error", + }, + { + Error: "", + Details: "details", + ExpectedError: "details", + }, + } { + r := NewSingleRule(func(v int) error { + if v < 0 { + return errors.Errorf(test.Error) + } + return nil + }). + WithErrorCode("test"). + WithDetails(test.Details) + + err := r.Validate(0) + assert.Nil(t, err) + err = r.Validate(-1) + assert.EqualError(t, err, test.ExpectedError) + assert.Equal(t, "test", err.(*RuleError).Code) + } +} diff --git a/pkg/govy/rules.go b/pkg/govy/rules.go new file mode 100644 index 0000000..fc316db --- /dev/null +++ b/pkg/govy/rules.go @@ -0,0 +1,260 @@ +package validation + +import ( + "github.com/pkg/errors" +) + +// For creates a new [PropertyRules] instance for the property +// which value is extracted through [PropertyGetter] function. +func For[T, S any](getter PropertyGetter[T, S]) PropertyRules[T, S] { + return PropertyRules[T, S]{getter: func(s S) (v T, err error) { return getter(s), nil }} +} + +// ForPointer accepts a getter function returning a pointer and wraps its call in order to +// safely extract the value under the pointer or return a zero value for a give type T. +// If required is set to true, the nil pointer value will result in an error and the +// validation will not proceed. +func ForPointer[T, S any](getter PropertyGetter[*T, S]) PropertyRules[T, S] { + return PropertyRules[T, S]{getter: func(s S) (indirect T, err error) { + ptr := getter(s) + if ptr != nil { + return *ptr, nil + } + zv := *new(T) + return zv, emptyErr{} + }, isPointer: true} +} + +// Transform transforms value from one type to another. +// Value returned by [PropertyGetter] is transformed through [Transformer] function. +// If [Transformer] returns an error, the validation will not proceed and transformation error will be reported. +// [Transformer] is only called if [PropertyGetter] returns a non-zero value. +func Transform[T, N, S any](getter PropertyGetter[T, S], transform Transformer[T, N]) PropertyRules[N, S] { + typInfo := getTypeInfo[T]() + return PropertyRules[N, S]{ + transformGetter: func(s S) (transformed N, original any, err error) { + v := getter(s) + if isEmptyFunc(v) { + return transformed, nil, emptyErr{} + } + transformed, err = transform(v) + if err != nil { + return transformed, v, NewRuleError(err.Error(), ErrorCodeTransform) + } + return transformed, v, nil + }, + originalType: &typInfo, + } +} + +// GetSelf is a convenience method for extracting 'self' property of a validated value. +func GetSelf[S any]() PropertyGetter[S, S] { + return func(s S) S { return s } +} + +type Transformer[T, N any] func(T) (N, error) + +type PropertyGetter[T, S any] func(S) T + +type internalPropertyGetter[T, S any] func(S) (v T, err error) +type internalTransformPropertyGetter[T, S any] func(S) (transformed T, original any, err error) +type emptyErr struct{} + +func (emptyErr) Error() string { return "" } + +// PropertyRules is responsible for validating a single property. +type PropertyRules[T, S any] struct { + name string + getter internalPropertyGetter[T, S] + transformGetter internalTransformPropertyGetter[T, S] + steps []interface{} + required bool + omitEmpty bool + hideValue bool + isPointer bool + mode CascadeMode + examples []string + originalType *typeInfo + + predicateMatcher[S] +} + +// Validate validates the property value using provided rules. +// nolint: gocognit +func (r PropertyRules[T, S]) Validate(st S) PropertyErrors { + var ( + ruleErrors []error + allErrors PropertyErrors + ) + propValue, skip, err := r.getValue(st) + if err != nil { + if r.hideValue { + err = err.HideValue() + } + return PropertyErrors{err} + } + if skip { + return nil + } + if !r.matchPredicates(st) { + return nil + } + for _, step := range r.steps { + stepFailed := false + switch v := step.(type) { + // Same as Rule[S] as for GetSelf we'd get the same type on T and S. + case Rule[T]: + err := v.Validate(propValue) + if err != nil { + stepFailed = true + switch ev := err.(type) { + case *PropertyError: + allErrors = append(allErrors, ev.PrependPropertyName(r.name)) + default: + ruleErrors = append(ruleErrors, err) + } + } + case validatorI[T]: + err := v.Validate(propValue) + if err != nil { + stepFailed = true + for _, e := range err.Errors { + allErrors = append(allErrors, e.PrependPropertyName(r.name)) + } + } + } + if stepFailed && r.mode == CascadeModeStop { + break + } + } + if len(ruleErrors) > 0 { + allErrors = append(allErrors, NewPropertyError(r.name, propValue, ruleErrors...)) + } + if len(allErrors) > 0 { + if r.hideValue { + allErrors = allErrors.HideValue() + } + return allErrors.Aggregate() + } + return nil +} + +func (r PropertyRules[T, S]) WithName(name string) PropertyRules[T, S] { + r.name = name + return r +} + +func (r PropertyRules[T, S]) WithExamples(examples ...string) PropertyRules[T, S] { + r.examples = append(r.examples, examples...) + return r +} + +func (r PropertyRules[T, S]) Rules(rules ...Rule[T]) PropertyRules[T, S] { + r.steps = appendSteps(r.steps, rules) + return r +} + +func (r PropertyRules[T, S]) Include(rules ...Validator[T]) PropertyRules[T, S] { + r.steps = appendSteps(r.steps, rules) + return r +} + +func (r PropertyRules[T, S]) When(predicate Predicate[S], opts ...WhenOptions) PropertyRules[T, S] { + r.predicateMatcher = r.when(predicate, opts...) + return r +} + +func (r PropertyRules[T, S]) Required() PropertyRules[T, S] { + r.required = true + return r +} + +func (r PropertyRules[T, S]) OmitEmpty() PropertyRules[T, S] { + r.omitEmpty = true + return r +} + +func (r PropertyRules[T, S]) HideValue() PropertyRules[T, S] { + r.hideValue = true + return r +} + +func (r PropertyRules[T, S]) Cascade(mode CascadeMode) PropertyRules[T, S] { + r.mode = mode + return r +} + +func (r PropertyRules[T, S]) plan(builder planBuilder) { + builder.propertyPlan.IsOptional = (r.omitEmpty || r.isPointer) && !r.required + builder.propertyPlan.IsHidden = r.hideValue + for _, predicate := range r.predicates { + builder.rulePlan.Conditions = append(builder.rulePlan.Conditions, predicate.description) + } + if r.originalType != nil { + builder.propertyPlan.Type = r.originalType.Name + builder.propertyPlan.Package = r.originalType.Package + } else { + typInfo := getTypeInfo[T]() + builder.propertyPlan.Type = typInfo.Name + builder.propertyPlan.Package = typInfo.Package + } + builder = builder.appendPath(r.name).setExamples(r.examples...) + for _, step := range r.steps { + if p, ok := step.(planner); ok { + p.plan(builder) + } + } + // If we don't have any rules defined for this property, append it nonetheless. + // It can be useful when we have things like [WithExamples] or [Required] set. + if len(r.steps) == 0 { + *builder.all = append(*builder.all, builder) + } +} + +func appendSteps[T any](slice []interface{}, steps []T) []interface{} { + for _, step := range steps { + slice = append(slice, step) + } + return slice +} + +// getValue extracts the property value from the provided property. +// It returns the value, a flag indicating whether the validation should be skipped, and any errors encountered. +func (r PropertyRules[T, S]) getValue(st S) (v T, skip bool, propErr *PropertyError) { + var ( + err error + originalValue any + ) + // Extract value from the property through correct getter. + if r.transformGetter != nil { + v, originalValue, err = r.transformGetter(st) + } else { + v, err = r.getter(st) + } + isEmptyError := errors.Is(err, emptyErr{}) + // Any error other than [emptyErr] is considered critical, we don't proceed with validation. + if err != nil && !isEmptyError { + var propValue interface{} + // If the value was transformed, we need to set the property value to the original, pre-transformed one. + if HasErrorCode(err, ErrorCodeTransform) { + propValue = originalValue + } else { + propValue = v + } + return v, false, NewPropertyError(r.name, propValue, err) + } + isEmpty := isEmptyError || (!r.isPointer && isEmptyFunc(v)) + // If the value is not empty we simply return it. + if !isEmpty { + return v, false, nil + } + // If the value is empty and the property is required, we return [ErrorCodeRequired]. + if r.required { + return v, false, NewPropertyError(r.name, nil, NewRequiredError()) + } + // If the value is empty and we're skipping empty values or the value is a pointer, we skip the validation. + if r.omitEmpty || r.isPointer { + return v, true, nil + } + return v, false, nil +} diff --git a/pkg/govy/rules_for_map.go b/pkg/govy/rules_for_map.go new file mode 100644 index 0000000..c2cc498 --- /dev/null +++ b/pkg/govy/rules_for_map.go @@ -0,0 +1,154 @@ +package validation + +import ( + "fmt" +) + +// ForMap creates a new [PropertyRulesForMap] instance for a map property +// which value is extracted through [PropertyGetter] function. +func ForMap[M ~map[K]V, K comparable, V, S any](getter PropertyGetter[M, S]) PropertyRulesForMap[M, K, V, S] { + return PropertyRulesForMap[M, K, V, S]{ + mapRules: For(getter), + forKeyRules: For(GetSelf[K]()), + forValueRules: For(GetSelf[V]()), + forItemRules: For(GetSelf[MapItem[K, V]]()), + getter: getter, + } +} + +// PropertyRulesForMap is responsible for validating a single property. +type PropertyRulesForMap[M ~map[K]V, K comparable, V, S any] struct { + mapRules PropertyRules[M, S] + forKeyRules PropertyRules[K, K] + forValueRules PropertyRules[V, V] + forItemRules PropertyRules[MapItem[K, V], MapItem[K, V]] + getter PropertyGetter[M, S] + mode CascadeMode + + predicateMatcher[S] +} + +// MapItem is a tuple container for map's key and value pair. +type MapItem[K comparable, V any] struct { + Key K + Value V +} + +// Validate executes each of the rules sequentially and aggregates the encountered errors. +func (r PropertyRulesForMap[M, K, V, S]) Validate(st S) PropertyErrors { + if !r.matchPredicates(st) { + return nil + } + err := r.mapRules.Validate(st) + if r.mode == CascadeModeStop && err != nil { + return err + } + for k, v := range r.getter(st) { + forKeyErr := r.forKeyRules.Validate(k) + for _, e := range forKeyErr { + e.IsKeyError = true + err = append(err, e.PrependPropertyName(MapElementName(r.mapRules.name, k))) + } + forValueErr := r.forValueRules.Validate(v) + for _, e := range forValueErr { + err = append(err, e.PrependPropertyName(MapElementName(r.mapRules.name, k))) + } + forItemErr := r.forItemRules.Validate(MapItem[K, V]{Key: k, Value: v}) + for _, e := range forItemErr { + // TODO: Figure out how to handle custom PropertyErrors. + // Custom errors' value for nested item will be overridden by the actual value. + e.PropertyValue = propertyValueString(v) + err = append(err, e.PrependPropertyName(MapElementName(r.mapRules.name, k))) + } + } + return err.Aggregate().Sort() +} + +func (r PropertyRulesForMap[M, K, V, S]) WithName(name string) PropertyRulesForMap[M, K, V, S] { + r.mapRules = r.mapRules.WithName(name) + return r +} + +func (r PropertyRulesForMap[M, K, V, S]) WithExamples(examples ...string) PropertyRulesForMap[M, K, V, S] { + r.mapRules = r.mapRules.WithExamples(examples...) + return r +} + +func (r PropertyRulesForMap[M, K, V, S]) RulesForKeys(rules ...Rule[K]) PropertyRulesForMap[M, K, V, S] { + r.forKeyRules = r.forKeyRules.Rules(rules...) + return r +} + +func (r PropertyRulesForMap[M, K, V, S]) RulesForValues(rules ...Rule[V]) PropertyRulesForMap[M, K, V, S] { + r.forValueRules = r.forValueRules.Rules(rules...) + return r +} + +func (r PropertyRulesForMap[M, K, V, S]) RulesForItems(rules ...Rule[MapItem[K, V]]) PropertyRulesForMap[M, K, V, S] { + r.forItemRules = r.forItemRules.Rules(rules...) + return r +} + +func (r PropertyRulesForMap[M, K, V, S]) Rules(rules ...Rule[M]) PropertyRulesForMap[M, K, V, S] { + r.mapRules = r.mapRules.Rules(rules...) + return r +} + +func (r PropertyRulesForMap[M, K, V, S]) When( + predicate Predicate[S], + opts ...WhenOptions, +) PropertyRulesForMap[M, K, V, S] { + r.predicateMatcher = r.when(predicate, opts...) + return r +} + +func (r PropertyRulesForMap[M, K, V, S]) IncludeForKeys(validators ...Validator[K]) PropertyRulesForMap[M, K, V, S] { + r.forKeyRules = r.forKeyRules.Include(validators...) + return r +} + +func (r PropertyRulesForMap[M, K, V, S]) IncludeForValues(rules ...Validator[V]) PropertyRulesForMap[M, K, V, S] { + r.forValueRules = r.forValueRules.Include(rules...) + return r +} + +func (r PropertyRulesForMap[M, K, V, S]) IncludeForItems( + rules ...Validator[MapItem[K, V]], +) PropertyRulesForMap[M, K, V, S] { + r.forItemRules = r.forItemRules.Include(rules...) + return r +} + +func (r PropertyRulesForMap[M, K, V, S]) Cascade(mode CascadeMode) PropertyRulesForMap[M, K, V, S] { + r.mode = mode + r.mapRules = r.mapRules.Cascade(mode) + r.forKeyRules = r.forKeyRules.Cascade(mode) + r.forValueRules = r.forValueRules.Cascade(mode) + r.forItemRules = r.forItemRules.Cascade(mode) + return r +} + +func (r PropertyRulesForMap[M, K, V, S]) plan(builder planBuilder) { + for _, predicate := range r.predicates { + builder.rulePlan.Conditions = append(builder.rulePlan.Conditions, predicate.description) + } + r.mapRules.plan(builder.setExamples(r.mapRules.examples...)) + builder = builder.appendPath(r.mapRules.name) + // JSON/YAML path for keys uses '~' to extract the keys. + if len(r.forKeyRules.steps) > 0 { + r.forKeyRules.plan(builder.appendPath("~")) + } + if len(r.forValueRules.steps) > 0 { + r.forValueRules.plan(builder.appendPath("*")) + } + if len(r.forItemRules.steps) > 0 { + r.forItemRules.plan(builder.appendPath("*")) + } +} + +func MapElementName(mapName, key any) string { + if mapName == "" { + return fmt.Sprintf("%v", key) + } + return fmt.Sprintf("%s.%v", mapName, key) +} diff --git a/pkg/govy/rules_for_map_test.go b/pkg/govy/rules_for_map_test.go new file mode 100644 index 0000000..8d25e26 --- /dev/null +++ b/pkg/govy/rules_for_map_test.go @@ -0,0 +1,391 @@ +package validation + +import ( + "testing" + + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPropertyRulesForMap(t *testing.T) { + type mockStruct struct { + StringMap map[string]string + IntMap map[string]int + } + + t.Run("no predicates, no error", func(t *testing.T) { + baseRules := ForMap(func(m mockStruct) map[string]string { return map[string]string{"key": "value"} }). + WithName("test.path") + for _, r := range []PropertyRulesForMap[map[string]string, string, string, mockStruct]{ + baseRules.RulesForKeys(NewSingleRule(func(v string) error { return nil })), + baseRules.RulesForValues(NewSingleRule(func(v string) error { return nil })), + baseRules.RulesForItems(NewSingleRule(func(v MapItem[string, string]) error { return nil })), + } { + errs := r.Validate(mockStruct{}) + assert.Nil(t, errs) + } + }) + + t.Run("no predicates, validate", func(t *testing.T) { + expectedErr := errors.New("ops!") + baseRules := ForMap(func(m mockStruct) map[string]string { return map[string]string{"key": "value"} }). + WithName("test.path") + for name, test := range map[string]struct { + Rules PropertyRulesForMap[map[string]string, string, string, mockStruct] + Expected *PropertyError + }{ + "keys": { + Rules: baseRules.RulesForKeys(NewSingleRule(func(v string) error { return expectedErr })), + Expected: &PropertyError{ + PropertyName: "test.path.key", + PropertyValue: "key", + IsKeyError: true, + Errors: []*RuleError{{Message: expectedErr.Error()}}, + }, + }, + "values": { + Rules: baseRules.RulesForValues(NewSingleRule(func(v string) error { return expectedErr })), + Expected: &PropertyError{ + PropertyName: "test.path.key", + PropertyValue: "value", + Errors: []*RuleError{{Message: expectedErr.Error()}}, + }, + }, + "items": { + Rules: baseRules.RulesForItems(NewSingleRule(func(v MapItem[string, string]) error { return expectedErr })), + Expected: &PropertyError{ + PropertyName: "test.path.key", + PropertyValue: "value", + Errors: []*RuleError{{Message: expectedErr.Error()}}, + }, + }, + } { + t.Run(name, func(t *testing.T) { + errs := test.Rules.Validate(mockStruct{}) + require.Len(t, errs, 1) + assert.Equal(t, test.Expected, errs[0]) + }) + } + }) + + t.Run("predicate matches, don't validate", func(t *testing.T) { + baseRules := ForMap(func(m mockStruct) map[string]string { return map[string]string{"key": "value"} }). + WithName("test.path"). + When(func(mockStruct) bool { return true }). + When(func(mockStruct) bool { return true }). + When(func(st mockStruct) bool { return len(st.StringMap) == 0 }) + for _, r := range []PropertyRulesForMap[map[string]string, string, string, mockStruct]{ + baseRules.RulesForKeys(NewSingleRule(func(v string) error { return errors.New("ops!") })), + baseRules.RulesForValues(NewSingleRule(func(v string) error { return errors.New("ops!") })), + baseRules.RulesForItems(NewSingleRule(func(v MapItem[string, string]) error { return errors.New("ops!") })), + } { + errs := r.Validate(mockStruct{StringMap: map[string]string{"different": "map"}}) + assert.Nil(t, errs) + } + }) + + t.Run("multiple rules for keys, values and items", func(t *testing.T) { + errRule := errors.New("rule error") + errKey := errors.New("key error") + errNestedKey := errors.New("nested key error") + errValue := errors.New("value error") + errNestedValue := errors.New("nested value error") + errItem := errors.New("value item") + errNestedItem := errors.New("nested item error") + errNestedRule := errors.New("nested rule error") + + r := ForMap(func(m mockStruct) map[string]string { return m.StringMap }). + WithName("test.path"). + Rules(NewSingleRule(func(v map[string]string) error { return errRule })). + RulesForKeys( + NewSingleRule(func(v string) error { return errKey }), + NewSingleRule(func(v string) error { + return NewPropertyError("nested", "nestedKey", errNestedKey) + }), + ). + RulesForValues( + NewSingleRule(func(v string) error { return errValue }), + NewSingleRule(func(v string) error { + return NewPropertyError("nested", "nestedValue", errNestedValue) + }), + ). + RulesForItems( + NewSingleRule(func(v MapItem[string, string]) error { return errItem }), + NewSingleRule(func(v MapItem[string, string]) error { + return NewPropertyError("nested", "nestedItem", errNestedItem) + }), + ). + Rules(NewSingleRule(func(v map[string]string) error { + return NewPropertyError("nested", "nestedRule", errNestedRule) + })) + + errs := r.Validate(mockStruct{StringMap: map[string]string{ + "key1": "value1", + "key2": "value2", + }}) + require.Len(t, errs, 12) + assert.ElementsMatch(t, []*PropertyError{ + { + PropertyName: "test.path", + PropertyValue: `{"key1":"value1","key2":"value2"}`, + Errors: []*RuleError{{Message: errRule.Error()}}, + }, + { + PropertyName: "test.path.key1", + PropertyValue: "key1", + IsKeyError: true, + Errors: []*RuleError{{Message: errKey.Error()}}, + }, + { + PropertyName: "test.path.key2", + PropertyValue: "key2", + IsKeyError: true, + Errors: []*RuleError{{Message: errKey.Error()}}, + }, + { + PropertyName: "test.path.key1.nested", + PropertyValue: "nestedKey", + IsKeyError: true, + Errors: []*RuleError{{Message: errNestedKey.Error()}}, + }, + { + PropertyName: "test.path.key2.nested", + PropertyValue: "nestedKey", + IsKeyError: true, + Errors: []*RuleError{{Message: errNestedKey.Error()}}, + }, + { + PropertyName: "test.path.key1", + PropertyValue: "value1", + Errors: []*RuleError{ + {Message: errValue.Error()}, + {Message: errItem.Error()}, + }, + }, + { + PropertyName: "test.path.key2", + PropertyValue: "value2", + Errors: []*RuleError{ + {Message: errValue.Error()}, + {Message: errItem.Error()}, + }, + }, + { + PropertyName: "test.path.key1.nested", + PropertyValue: "nestedValue", + Errors: []*RuleError{{Message: errNestedValue.Error()}}, + }, + { + PropertyName: "test.path.key2.nested", + PropertyValue: "nestedValue", + Errors: []*RuleError{{Message: errNestedValue.Error()}}, + }, + { + PropertyName: "test.path.key1.nested", + PropertyValue: "value1", + Errors: []*RuleError{{Message: errNestedItem.Error()}}, + }, + { + PropertyName: "test.path.key2.nested", + PropertyValue: "value2", + Errors: []*RuleError{{Message: errNestedItem.Error()}}, + }, + { + PropertyName: "test.path.nested", + PropertyValue: "nestedRule", + Errors: []*RuleError{{Message: errNestedRule.Error()}}, + }, + }, errs) + }) + + t.Run("cascade mode stop", func(t *testing.T) { + keyErr := errors.New("key error") + valueErr := errors.New("value error") + r := ForMap(func(m mockStruct) map[string]string { return map[string]string{"key": "value"} }). + WithName("test.path"). + Cascade(CascadeModeStop). + RulesForValues(NewSingleRule(func(v string) error { return valueErr })). + RulesForKeys(NewSingleRule(func(v string) error { return keyErr })) + errs := r.Validate(mockStruct{}) + require.Len(t, errs, 2) + assert.ElementsMatch(t, []*PropertyError{ + { + PropertyName: "test.path.key", + PropertyValue: "key", + IsKeyError: true, + Errors: []*RuleError{{Message: keyErr.Error()}}, + }, + { + PropertyName: "test.path.key", + PropertyValue: "value", + Errors: []*RuleError{{Message: valueErr.Error()}}, + }, + }, errs) + }) + + t.Run("include for keys validator", func(t *testing.T) { + errRule := errors.New("rule error") + errIncludedKey1 := errors.New("included key 1 error") + errIncludedKey2 := errors.New("included key 2 error") + errIncludedValue1 := errors.New("included value 1 error") + errIncludedValue2 := errors.New("included value 2 error") + errIncludedItem1 := errors.New("included item 1 error") + errIncludedItem2 := errors.New("included item 2 error") + + r := ForMap(func(m mockStruct) map[string]int { return m.IntMap }). + WithName("test.path"). + Rules(NewSingleRule(func(v map[string]int) error { return errRule })). + IncludeForKeys(New( + For(func(s string) string { return s }). + WithName("included_key"). + Rules( + NewSingleRule(func(v string) error { return errIncludedKey1 }), + NewSingleRule(func(v string) error { return errIncludedKey2 }), + ), + )). + IncludeForValues(New( + For(func(i int) int { return i }). + WithName("included_value"). + Rules( + NewSingleRule(func(v int) error { return errIncludedValue1 }), + NewSingleRule(func(v int) error { return errIncludedValue2 }), + ), + )). + IncludeForItems(New( + For(func(i MapItem[string, int]) MapItem[string, int] { return i }). + WithName("included_item"). + Rules( + NewSingleRule(func(v MapItem[string, int]) error { return errIncludedItem1 }), + NewSingleRule(func(v MapItem[string, int]) error { return errIncludedItem2 }), + ), + )) + + errs := r.Validate(mockStruct{IntMap: map[string]int{"key": 1}}) + require.Len(t, errs, 4) + assert.ElementsMatch(t, []*PropertyError{ + { + PropertyName: "test.path", + PropertyValue: `{"key":1}`, + Errors: []*RuleError{{Message: errRule.Error()}}, + }, + { + PropertyName: "test.path.key.included_key", + PropertyValue: "key", + IsKeyError: true, + Errors: []*RuleError{ + {Message: errIncludedKey1.Error()}, + {Message: errIncludedKey2.Error()}, + }, + }, + { + PropertyName: "test.path.key.included_value", + PropertyValue: "1", + Errors: []*RuleError{ + {Message: errIncludedValue1.Error()}, + {Message: errIncludedValue2.Error()}, + }, + }, + { + PropertyName: "test.path.key.included_item", + PropertyValue: "1", + Errors: []*RuleError{ + {Message: errIncludedItem1.Error()}, + {Message: errIncludedItem2.Error()}, + }, + }, + }, errs) + }) + + t.Run("include for keys validator, key and value are same type", func(t *testing.T) { + errRule := errors.New("rule error") + errIncludedKey1 := errors.New("included key 1 error") + errIncludedKey2 := errors.New("included key 2 error") + errIncludedValue1 := errors.New("included value 1 error") + errIncludedValue2 := errors.New("included value 2 error") + errIncludedItem1 := errors.New("included item 1 error") + errIncludedItem2 := errors.New("included item 2 error") + + r := ForMap(func(m mockStruct) map[string]string { return m.StringMap }). + WithName("test.path"). + Rules(NewSingleRule(func(v map[string]string) error { return errRule })). + IncludeForKeys(New( + For(func(s string) string { return s }). + WithName("included_key"). + Rules( + NewSingleRule(func(v string) error { return errIncludedKey1 }), + NewSingleRule(func(v string) error { return errIncludedKey2 }), + ), + )). + IncludeForValues(New( + For(func(i string) string { return i }). + WithName("included_value"). + Rules( + NewSingleRule(func(v string) error { return errIncludedValue1 }), + NewSingleRule(func(v string) error { return errIncludedValue2 }), + ), + )). + IncludeForItems(New( + For(func(i MapItem[string, string]) MapItem[string, string] { return i }). + WithName("included_item"). + Rules( + NewSingleRule(func(v MapItem[string, string]) error { return errIncludedItem1 }), + NewSingleRule(func(v MapItem[string, string]) error { return errIncludedItem2 }), + ), + )) + + errs := r.Validate(mockStruct{StringMap: map[string]string{"key": "1"}}) + require.Len(t, errs, 4) + assert.ElementsMatch(t, []*PropertyError{ + { + PropertyName: "test.path", + PropertyValue: `{"key":"1"}`, + Errors: []*RuleError{{Message: errRule.Error()}}, + }, + { + PropertyName: "test.path.key.included_key", + PropertyValue: "key", + IsKeyError: true, + Errors: []*RuleError{ + {Message: errIncludedKey1.Error()}, + {Message: errIncludedKey2.Error()}, + }, + }, + { + PropertyName: "test.path.key.included_value", + PropertyValue: "1", + Errors: []*RuleError{ + {Message: errIncludedValue1.Error()}, + {Message: errIncludedValue2.Error()}, + }, + }, + { + PropertyName: "test.path.key.included_item", + PropertyValue: "1", + Errors: []*RuleError{ + {Message: errIncludedItem1.Error()}, + {Message: errIncludedItem2.Error()}, + }, + }, + }, errs) + }) + + t.Run("include nested for map", func(t *testing.T) { + expectedErr := errors.New("oh no!") + inc := New( + ForMap(GetSelf[map[string]string]()). + RulesForValues(NewSingleRule(func(v string) error { return expectedErr })), + ) + r := For(func(m mockStruct) map[string]string { return m.StringMap }). + WithName("test.path"). + Include(inc) + + errs := r.Validate(mockStruct{StringMap: map[string]string{"key": "value"}}) + require.Len(t, errs, 1) + assert.Equal(t, &PropertyError{ + PropertyName: "test.path.key", + PropertyValue: "value", + Errors: []*RuleError{{Message: expectedErr.Error()}}, + }, errs[0]) + }) +} diff --git a/pkg/govy/rules_for_slice.go b/pkg/govy/rules_for_slice.go new file mode 100644 index 0000000..13fcccc --- /dev/null +++ b/pkg/govy/rules_for_slice.go @@ -0,0 +1,101 @@ +package validation + +import "fmt" + +// ForSlice creates a new [PropertyRulesForSlice] instance for a slice property +// which value is extracted through [PropertyGetter] function. +func ForSlice[T, S any](getter PropertyGetter[[]T, S]) PropertyRulesForSlice[T, S] { + return PropertyRulesForSlice[T, S]{ + sliceRules: For(GetSelf[[]T]()), + forEachRules: For(GetSelf[T]()), + getter: getter, + } +} + +// PropertyRulesForSlice is responsible for validating a single property. +type PropertyRulesForSlice[T, S any] struct { + sliceRules PropertyRules[[]T, []T] + forEachRules PropertyRules[T, T] + getter PropertyGetter[[]T, S] + mode CascadeMode + + predicateMatcher[S] +} + +// Validate executes each of the rules sequentially and aggregates the encountered errors. +func (r PropertyRulesForSlice[T, S]) Validate(st S) PropertyErrors { + if !r.matchPredicates(st) { + return nil + } + v := r.getter(st) + err := r.sliceRules.Validate(v) + if r.mode == CascadeModeStop && err != nil { + return err + } + for i, element := range v { + forEachErr := r.forEachRules.Validate(element) + if forEachErr == nil { + continue + } + for _, e := range forEachErr { + e.IsSliceElementError = true + err = append(err, e.PrependPropertyName(SliceElementName(r.sliceRules.name, i))) + } + } + return err.Aggregate() +} + +func (r PropertyRulesForSlice[T, S]) WithName(name string) PropertyRulesForSlice[T, S] { + r.sliceRules = r.sliceRules.WithName(name) + return r +} + +func (r PropertyRulesForSlice[T, S]) WithExamples(examples ...string) PropertyRulesForSlice[T, S] { + r.sliceRules = r.sliceRules.WithExamples(examples...) + return r +} + +func (r PropertyRulesForSlice[T, S]) RulesForEach(rules ...Rule[T]) PropertyRulesForSlice[T, S] { + r.forEachRules = r.forEachRules.Rules(rules...) + return r +} + +func (r PropertyRulesForSlice[T, S]) Rules(rules ...Rule[[]T]) PropertyRulesForSlice[T, S] { + r.sliceRules = r.sliceRules.Rules(rules...) + return r +} + +func (r PropertyRulesForSlice[T, S]) When(predicate Predicate[S], opts ...WhenOptions) PropertyRulesForSlice[T, S] { + r.predicateMatcher = r.when(predicate, opts...) + return r +} + +func (r PropertyRulesForSlice[T, S]) IncludeForEach(rules ...Validator[T]) PropertyRulesForSlice[T, S] { + r.forEachRules = r.forEachRules.Include(rules...) + return r +} + +func (r PropertyRulesForSlice[T, S]) Cascade(mode CascadeMode) PropertyRulesForSlice[T, S] { + r.mode = mode + r.sliceRules = r.sliceRules.Cascade(mode) + r.forEachRules = r.forEachRules.Cascade(mode) + return r +} + +func (r PropertyRulesForSlice[T, S]) plan(builder planBuilder) { + for _, predicate := range r.predicates { + builder.rulePlan.Conditions = append(builder.rulePlan.Conditions, predicate.description) + } + r.sliceRules.plan(builder.setExamples(r.sliceRules.examples...)) + builder = builder.appendPath(r.sliceRules.name) + if len(r.forEachRules.steps) > 0 { + r.forEachRules.plan(builder.appendPath("[*]")) + } +} + +func SliceElementName(sliceName string, index int) string { + if sliceName == "" { + return fmt.Sprintf("[%d]", index) + } + return fmt.Sprintf("%s[%d]", sliceName, index) +} diff --git a/pkg/govy/rules_for_slice_test.go b/pkg/govy/rules_for_slice_test.go new file mode 100644 index 0000000..6af7d16 --- /dev/null +++ b/pkg/govy/rules_for_slice_test.go @@ -0,0 +1,194 @@ +package validation + +import ( + "testing" + + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPropertyRulesForEach(t *testing.T) { + type mockStruct struct { + Fields []string + } + + t.Run("no predicates, no error", func(t *testing.T) { + r := ForSlice(func(m mockStruct) []string { return []string{"path"} }). + WithName("test.path"). + RulesForEach(NewSingleRule(func(v string) error { return nil })) + errs := r.Validate(mockStruct{}) + assert.Nil(t, errs) + }) + + t.Run("no predicates, validate", func(t *testing.T) { + expectedErr := errors.New("ops!") + r := ForSlice(func(m mockStruct) []string { return []string{"path"} }). + WithName("test.path"). + RulesForEach(NewSingleRule(func(v string) error { return expectedErr })) + errs := r.Validate(mockStruct{}) + require.Len(t, errs, 1) + assert.Equal(t, &PropertyError{ + PropertyName: "test.path[0]", + PropertyValue: "path", + IsSliceElementError: true, + Errors: []*RuleError{{Message: expectedErr.Error()}}, + }, errs[0]) + }) + + t.Run("predicate matches, don't validate", func(t *testing.T) { + r := ForSlice(func(m mockStruct) []string { return []string{"value"} }). + WithName("test.path"). + When(func(mockStruct) bool { return true }). + When(func(mockStruct) bool { return true }). + When(func(st mockStruct) bool { return len(st.Fields) == 0 }). + RulesForEach(NewSingleRule(func(v string) error { return errors.New("ops!") })) + errs := r.Validate(mockStruct{Fields: []string{"something"}}) + assert.Nil(t, errs) + }) + + t.Run("multiple rules and for each rules", func(t *testing.T) { + err1 := errors.New("oh no!") + err2 := errors.New("another error...") + err3 := errors.New("rule error") + err4 := errors.New("rule error again") + r := ForSlice(func(m mockStruct) []string { return m.Fields }). + WithName("test.path"). + Rules(NewSingleRule(func(v []string) error { return err3 })). + RulesForEach( + NewSingleRule(func(v string) error { return err1 }), + NewSingleRule(func(v string) error { + return NewPropertyError("nested", "made-up", err2) + }), + ). + Rules(NewSingleRule(func(v []string) error { + return NewPropertyError("nested", "nestedValue", err4) + })) + + errs := r.Validate(mockStruct{Fields: []string{"1", "2"}}) + require.Len(t, errs, 6) + assert.ElementsMatch(t, []*PropertyError{ + { + PropertyName: "test.path", + PropertyValue: `["1","2"]`, + Errors: []*RuleError{{Message: err3.Error()}}, + }, + { + PropertyName: "test.path.nested", + PropertyValue: "nestedValue", + Errors: []*RuleError{{Message: err4.Error()}}, + }, + { + PropertyName: "test.path[0]", + PropertyValue: "1", + IsSliceElementError: true, + Errors: []*RuleError{{Message: err1.Error()}}, + }, + { + PropertyName: "test.path[1]", + PropertyValue: "2", + IsSliceElementError: true, + Errors: []*RuleError{{Message: err1.Error()}}, + }, + { + PropertyName: "test.path[0].nested", + PropertyValue: "made-up", + IsSliceElementError: true, + Errors: []*RuleError{{Message: err2.Error()}}, + }, + { + PropertyName: "test.path[1].nested", + PropertyValue: "made-up", + IsSliceElementError: true, + Errors: []*RuleError{{Message: err2.Error()}}, + }, + }, errs) + }) + + t.Run("cascade mode stop", func(t *testing.T) { + expectedErr := errors.New("oh no!") + r := ForSlice(func(m mockStruct) []string { return []string{"value"} }). + WithName("test.path"). + Cascade(CascadeModeStop). + RulesForEach(NewSingleRule(func(v string) error { return expectedErr })). + RulesForEach(NewSingleRule(func(v string) error { return errors.New("no") })) + errs := r.Validate(mockStruct{}) + require.Len(t, errs, 1) + assert.Equal(t, &PropertyError{ + PropertyName: "test.path[0]", + PropertyValue: "value", + IsSliceElementError: true, + Errors: []*RuleError{{Message: expectedErr.Error()}}, + }, errs[0]) + }) + + t.Run("include for each validator", func(t *testing.T) { + err1 := errors.New("oh no!") + err2 := errors.New("included") + err3 := errors.New("included again") + r := ForSlice(func(m mockStruct) []string { return m.Fields }). + WithName("test.path"). + RulesForEach(NewSingleRule(func(v string) error { return err1 })). + IncludeForEach(New( + For(func(s string) string { return "nested" }). + WithName("included"). + Rules( + NewSingleRule(func(v string) error { return err2 }), + NewSingleRule(func(v string) error { return err3 }), + ), + )) + errs := r.Validate(mockStruct{Fields: []string{"value"}}) + require.Len(t, errs, 2) + assert.ElementsMatch(t, []*PropertyError{ + { + PropertyName: "test.path[0]", + PropertyValue: "value", + IsSliceElementError: true, + Errors: []*RuleError{{Message: err1.Error()}}, + }, + { + PropertyName: "test.path[0].included", + PropertyValue: "nested", + IsSliceElementError: true, + Errors: []*RuleError{ + {Message: err2.Error()}, + {Message: err3.Error()}, + }, + }, + }, errs) + }) + + t.Run("include nested for slice", func(t *testing.T) { + forEachErr := errors.New("oh no!") + includedErr := errors.New("oh no!") + inc := New( + ForSlice(GetSelf[[]string]()). + RulesForEach(NewSingleRule(func(v string) error { + if v == "value1" { + return forEachErr + } + return NewPropertyError("nested", "made-up", includedErr) + })), + ) + r := For(func(m mockStruct) []string { return m.Fields }). + WithName("test.path"). + Include(inc) + + errs := r.Validate(mockStruct{Fields: []string{"value1", "value2"}}) + require.Len(t, errs, 2) + assert.ElementsMatch(t, []*PropertyError{ + { + PropertyName: "test.path[0]", + PropertyValue: "value1", + IsSliceElementError: true, + Errors: []*RuleError{{Message: forEachErr.Error()}}, + }, + { + PropertyName: "test.path[1].nested", + PropertyValue: "made-up", + IsSliceElementError: true, + Errors: []*RuleError{{Message: includedErr.Error()}}, + }, + }, errs) + }) +} diff --git a/pkg/govy/rules_test.go b/pkg/govy/rules_test.go new file mode 100644 index 0000000..42db91a --- /dev/null +++ b/pkg/govy/rules_test.go @@ -0,0 +1,291 @@ +package validation + +import ( + "strconv" + "testing" + + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPropertyRules(t *testing.T) { + type mockStruct struct { + Field string + Fields []string + } + + t.Run("no predicates, no error", func(t *testing.T) { + r := For(func(m mockStruct) string { return "path" }). + WithName("test.path"). + Rules(NewSingleRule(func(v string) error { return nil })) + err := r.Validate(mockStruct{}) + assert.Nil(t, err) + }) + + t.Run("no predicates, validate", func(t *testing.T) { + expectedErr := errors.New("ops!") + r := For(func(m mockStruct) string { return "path" }). + WithName("test.path"). + Rules(NewSingleRule(func(v string) error { return expectedErr })) + errs := r.Validate(mockStruct{}) + require.Len(t, errs, 1) + assert.Equal(t, &PropertyError{ + PropertyName: "test.path", + PropertyValue: "path", + Errors: []*RuleError{{Message: expectedErr.Error()}}, + }, errs[0]) + }) + + t.Run("predicate matches, don't validate", func(t *testing.T) { + r := For(func(m mockStruct) string { return "value" }). + WithName("test.path"). + When(func(mockStruct) bool { return true }). + When(func(mockStruct) bool { return true }). + When(func(st mockStruct) bool { return st.Field == "" }). + Rules(NewSingleRule(func(v string) error { return errors.New("ops!") })) + err := r.Validate(mockStruct{Field: "something"}) + assert.Nil(t, err) + }) + + t.Run("multiple rules", func(t *testing.T) { + err1 := errors.New("oh no!") + r := For(func(m mockStruct) string { return "value" }). + WithName("test.path"). + Rules(NewSingleRule(func(v string) error { return nil })). + Rules(NewSingleRule(func(v string) error { return err1 })). + Rules(NewSingleRule(func(v string) error { return nil })). + Rules(NewSingleRule(func(v string) error { + return NewPropertyError("nested", "nestedValue", &RuleError{ + Message: "property is required", + Code: ErrorCodeRequired, + }) + })) + errs := r.Validate(mockStruct{}) + require.Len(t, errs, 2) + assert.ElementsMatch(t, PropertyErrors{ + &PropertyError{ + PropertyName: "test.path", + PropertyValue: "value", + Errors: []*RuleError{{Message: err1.Error()}}, + }, + &PropertyError{ + PropertyName: "test.path.nested", + PropertyValue: "nestedValue", + Errors: []*RuleError{{ + Message: "property is required", + Code: ErrorCodeRequired, + }}, + }, + }, errs) + }) + + t.Run("cascade mode stop", func(t *testing.T) { + expectedErr := errors.New("oh no!") + r := For(func(m mockStruct) string { return "value" }). + WithName("test.path"). + Cascade(CascadeModeStop). + Rules(NewSingleRule(func(v string) error { return expectedErr })). + Rules(NewSingleRule(func(v string) error { return errors.New("no") })) + errs := r.Validate(mockStruct{}) + require.Len(t, errs, 1) + assert.Equal(t, &PropertyError{ + PropertyName: "test.path", + PropertyValue: "value", + Errors: []*RuleError{{Message: expectedErr.Error()}}, + }, errs[0]) + }) + + t.Run("include validator", func(t *testing.T) { + err1 := errors.New("oh no!") + err2 := errors.New("included") + err3 := errors.New("included again") + r := For(func(m mockStruct) mockStruct { return m }). + WithName("test.path"). + Rules(NewSingleRule(func(v mockStruct) error { return err1 })). + Include(New( + For(func(s mockStruct) string { return "value" }). + WithName("included"). + Rules(NewSingleRule(func(v string) error { return err2 })). + Rules(NewSingleRule(func(v string) error { + return NewPropertyError("nested", "nestedValue", err3) + })), + )) + errs := r.Validate(mockStruct{}) + require.Len(t, errs, 3) + assert.ElementsMatch(t, PropertyErrors{ + { + PropertyName: "test.path", + Errors: []*RuleError{{Message: err1.Error()}}, + }, + { + PropertyName: "test.path.included", + PropertyValue: "value", + Errors: []*RuleError{{Message: err2.Error()}}, + }, + { + PropertyName: "test.path.included.nested", + PropertyValue: "nestedValue", + Errors: []*RuleError{{Message: err3.Error()}}, + }, + }, errs) + }) + + t.Run("get self", func(t *testing.T) { + expectedErrs := errors.New("self error") + r := For(GetSelf[mockStruct]()). + WithName("test.path"). + Rules(NewSingleRule(func(v mockStruct) error { return expectedErrs })) + object := mockStruct{Field: "this"} + errs := r.Validate(object) + require.Len(t, errs, 1) + assert.Equal(t, &PropertyError{ + PropertyName: "test.path", + PropertyValue: propertyValueString(object), + Errors: []*RuleError{{Message: expectedErrs.Error()}}, + }, errs[0]) + }) + + t.Run("hide value", func(t *testing.T) { + expectedErr := errors.New("oh no! here's the value: 'secret'") + r := For(func(m mockStruct) string { return "secret" }). + WithName("test.path"). + HideValue(). + Rules(NewSingleRule(func(v string) error { return expectedErr })) + errs := r.Validate(mockStruct{}) + require.Len(t, errs, 1) + assert.Equal(t, &PropertyError{ + PropertyName: "test.path", + PropertyValue: "", + Errors: []*RuleError{{Message: "oh no! here's the value: '[hidden]'"}}, + }, errs[0]) + }) +} + +func TestForPointer(t *testing.T) { + t.Run("nil pointer", func(t *testing.T) { + r := ForPointer(func(s *string) *string { return s }) + v, err := r.getter(nil) + assert.Equal(t, "", v) + assert.ErrorIs(t, err, emptyErr{}) + }) + t.Run("non nil pointer", func(t *testing.T) { + r := ForPointer(func(s *string) *string { return s }) + s := "this string" + v, err := r.getter(&s) + assert.Equal(t, s, v) + assert.NoError(t, err) + }) +} + +func TestRequiredAndOmitEmpty(t *testing.T) { + t.Run("nil pointer", func(t *testing.T) { + rules := ForPointer(func(s *string) *string { return s }). + Rules(StringMinLength(10)) + + t.Run("implicit omitEmpty", func(t *testing.T) { + err := rules.Validate(nil) + assert.Nil(t, err) + }) + t.Run("explicit omitEmpty", func(t *testing.T) { + err := rules.OmitEmpty().Validate(nil) + assert.Nil(t, err) + }) + t.Run("required", func(t *testing.T) { + errs := rules.Required().Validate(nil) + assert.Len(t, errs, 1) + assert.True(t, HasErrorCode(errs, ErrorCodeRequired)) + }) + }) + + t.Run("non empty pointer", func(t *testing.T) { + rules := ForPointer(func(s *string) *string { return s }). + Rules(StringMinLength(10)) + + t.Run("validate", func(t *testing.T) { + errs := rules.Validate(ptr("")) + assert.Len(t, errs, 1) + assert.True(t, HasErrorCode(errs, ErrorCodeStringMinLength)) + }) + t.Run("omitEmpty", func(t *testing.T) { + errs := rules.OmitEmpty().Validate(ptr("")) + assert.Len(t, errs, 1) + assert.True(t, HasErrorCode(errs, ErrorCodeStringMinLength)) + }) + t.Run("required", func(t *testing.T) { + errs := rules.Required().Validate(ptr("")) + assert.Len(t, errs, 1) + assert.True(t, HasErrorCode(errs, ErrorCodeStringMinLength)) + }) + }) +} + +func TestTransform(t *testing.T) { + t.Run("passes", func(t *testing.T) { + getter := func(s string) string { return s } + transformed := Transform(getter, strconv.Atoi). + Rules(GreaterThan(122)) + errs := transformed.Validate("123") + assert.Empty(t, errs) + }) + t.Run("fails validation", func(t *testing.T) { + getter := func(s string) string { return s } + transformed := Transform(getter, strconv.Atoi). + WithName("prop"). + Rules(GreaterThan(123)) + errs := transformed.Validate("123") + assert.Len(t, errs, 1) + assert.True(t, HasErrorCode(errs, ErrorCodeGreaterThan)) + }) + t.Run("zero value with omitEmpty", func(t *testing.T) { + getter := func(s string) string { return s } + transformed := Transform(getter, strconv.Atoi). + WithName("prop"). + OmitEmpty(). + Rules(GreaterThan(123)) + errs := transformed.Validate("") + assert.Empty(t, errs) + }) + t.Run("zero value with required", func(t *testing.T) { + getter := func(s string) string { return s } + transformed := Transform(getter, strconv.Atoi). + WithName("prop"). + Required(). + Rules(GreaterThan(123)) + errs := transformed.Validate("") + assert.Len(t, errs, 1) + assert.True(t, HasErrorCode(errs, ErrorCodeRequired)) + }) + t.Run("skip zero value", func(t *testing.T) { + getter := func(s string) string { return s } + transformed := Transform(getter, strconv.Atoi). + WithName("prop"). + Rules(GreaterThan(123)) + errs := transformed.Validate("") + assert.Len(t, errs, 1) + assert.True(t, HasErrorCode(errs, ErrorCodeGreaterThan)) + }) + t.Run("fails transformation", func(t *testing.T) { + getter := func(s string) string { return s } + transformed := Transform(getter, strconv.Atoi). + WithName("prop"). + Rules(GreaterThan(123)) + errs := transformed.Validate("123z") + assert.Len(t, errs, 1) + assert.EqualError(t, errs, expectedErrorOutput(t, "property_error_transform.txt")) + assert.True(t, HasErrorCode(errs, ErrorCodeTransform)) + }) + t.Run("fail transformation with hidden value", func(t *testing.T) { + getter := func(s string) string { return s } + transformed := Transform(getter, strconv.Atoi). + WithName("prop"). + HideValue(). + Rules(GreaterThan(123)) + errs := transformed.Validate("secret!") + assert.Len(t, errs, 1) + assert.EqualError(t, errs, expectedErrorOutput(t, "property_error_transform_with_hidden_value.txt")) + assert.True(t, HasErrorCode(errs, ErrorCodeTransform)) + }) +} + +func ptr[T any](v T) *T { return &v } diff --git a/pkg/govy/string.go b/pkg/govy/string.go new file mode 100644 index 0000000..351c627 --- /dev/null +++ b/pkg/govy/string.go @@ -0,0 +1,186 @@ +package validation + +import ( + "encoding/json" + "fmt" + "net/url" + "regexp" + "strings" + + "github.com/pkg/errors" +) + +func StringNotEmpty() SingleRule[string] { + msg := "string cannot be empty" + return NewSingleRule(func(s string) error { + if len(strings.TrimSpace(s)) == 0 { + return errors.New(msg) + } + return nil + }). + WithErrorCode(ErrorCodeStringNotEmpty). + WithDescription(msg) +} + +func StringMatchRegexp(re *regexp.Regexp, examples ...string) SingleRule[string] { + msg := fmt.Sprintf("string must match regular expression: '%s'", re.String()) + if len(examples) > 0 { + msg += " " + prettyExamples(examples) + } + return NewSingleRule(func(s string) error { + if !re.MatchString(s) { + return errors.New(msg) + } + return nil + }). + WithErrorCode(ErrorCodeStringMatchRegexp). + WithDescription(msg) +} + +func StringDenyRegexp(re *regexp.Regexp, examples ...string) SingleRule[string] { + msg := fmt.Sprintf("string must not match regular expression: '%s'", re.String()) + if len(examples) > 0 { + msg += " " + prettyExamples(examples) + } + return NewSingleRule(func(s string) error { + if re.MatchString(s) { + return errors.New(msg) + } + return nil + }). + WithErrorCode(ErrorCodeStringDenyRegexp). + WithDescription(msg) +} + +var dns1123SubdomainRegexp = regexp.MustCompile("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$") + +func StringIsDNSSubdomain() RuleSet[string] { + return NewRuleSet( + StringLength(1, 63), + StringMatchRegexp(dns1123SubdomainRegexp, "my-name", "123-abc"). + WithDetails("a DNS-1123 compliant name must consist of lower case alphanumeric characters or '-',"+ + " and must start and end with an alphanumeric character"), + ).WithErrorCode(ErrorCodeStringIsDNSSubdomain) +} + +var validUUIDRegex = regexp. + MustCompile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") + +func StringUUID() SingleRule[string] { + return StringMatchRegexp(validUUIDRegex, + "00000000-0000-0000-0000-000000000000", + "e190c630-8873-11ee-b9d1-0242ac120002", + "79258D24-01A7-47E5-ACBB-7E762DE52298"). + WithDetails("expected RFC-4122 compliant UUID string"). + WithErrorCode(ErrorCodeStringUUID) +} + +var asciiRegexp = regexp.MustCompile("^[\x00-\x7F]*$") + +func StringASCII() SingleRule[string] { + return StringMatchRegexp(asciiRegexp).WithErrorCode(ErrorCodeStringASCII) +} + +func StringDescription() SingleRule[string] { + return StringLength(0, 1050).WithErrorCode(ErrorCodeStringDescription) +} + +func StringURL() SingleRule[string] { + return NewSingleRule(func(v string) error { + u, err := url.Parse(v) + if err != nil { + return errors.Wrap(err, "failed to parse URL") + } + return validateURL(u) + }). + WithErrorCode(ErrorCodeStringURL). + WithDescription(urlDescription) +} + +func StringJSON() SingleRule[string] { + msg := "string must be a valid JSON" + return NewSingleRule(func(s string) error { + if !json.Valid([]byte(s)) { + return errors.New(msg) + } + return nil + }). + WithErrorCode(ErrorCodeStringJSON). + WithDescription(msg) +} + +func StringContains(substrings ...string) SingleRule[string] { + msg := "string must contain the following substrings: " + prettyStringList(substrings) + return NewSingleRule(func(s string) error { + matched := true + for _, substr := range substrings { + if !strings.Contains(s, substr) { + matched = false + break + } + } + if !matched { + return errors.New(msg) + } + return nil + }). + WithErrorCode(ErrorCodeStringContains). + WithDescription(msg) +} + +func StringStartsWith(prefixes ...string) SingleRule[string] { + var msg string + if len(prefixes) == 1 { + msg = fmt.Sprintf("string must start with '%s' prefix", prefixes[0]) + } else { + msg = "string must start with one of the following prefixes: " + prettyStringList(prefixes) + } + return NewSingleRule(func(s string) error { + matched := false + for _, prefix := range prefixes { + if strings.HasPrefix(s, prefix) { + matched = true + break + } + } + if !matched { + return errors.New(msg) + } + return nil + }). + WithErrorCode(ErrorCodeStringStartsWith). + WithDescription(msg) +} + +func prettyExamples(examples []string) string { + if len(examples) == 0 { + return "" + } + b := strings.Builder{} + b.WriteString("(e.g. ") + prettyStringListBuilder(&b, examples, true) + b.WriteString(")") + return b.String() +} + +func prettyStringList[T any](values []T) string { + b := new(strings.Builder) + prettyStringListBuilder(b, values, true) + return b.String() +} + +func prettyStringListBuilder[T any](b *strings.Builder, values []T, surroundInSingleQuotes bool) { + b.Grow(len(values)) + for i := range values { + if i > 0 { + b.WriteString(", ") + } + if surroundInSingleQuotes { + b.WriteString("'") + } + fmt.Fprint(b, values[i]) + if surroundInSingleQuotes { + b.WriteString("'") + } + } +} diff --git a/pkg/govy/string_test.go b/pkg/govy/string_test.go new file mode 100644 index 0000000..886d7b0 --- /dev/null +++ b/pkg/govy/string_test.go @@ -0,0 +1,224 @@ +package validation + +import ( + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestStringNotEmpty(t *testing.T) { + t.Run("passes", func(t *testing.T) { + err := StringNotEmpty().Validate(" s") + assert.NoError(t, err) + }) + t.Run("fails", func(t *testing.T) { + err := StringNotEmpty().Validate(" ") + assert.Error(t, err) + assert.True(t, HasErrorCode(err, ErrorCodeStringNotEmpty)) + }) +} + +func TestStringMatchRegexp(t *testing.T) { + re := regexp.MustCompile("[ab]+") + t.Run("passes", func(t *testing.T) { + err := StringMatchRegexp(re).Validate("ab") + assert.NoError(t, err) + }) + t.Run("fails", func(t *testing.T) { + err := StringMatchRegexp(re).Validate("cd") + assert.EqualError(t, err, "string must match regular expression: '[ab]+'") + assert.True(t, HasErrorCode(err, ErrorCodeStringMatchRegexp)) + }) + t.Run("examples output", func(t *testing.T) { + err := StringMatchRegexp(re, "ab", "a", "b").Validate("cd") + assert.EqualError(t, err, "string must match regular expression: '[ab]+' (e.g. 'ab', 'a', 'b')") + assert.True(t, HasErrorCode(err, ErrorCodeStringMatchRegexp)) + }) +} + +func TestStringDenyRegexp(t *testing.T) { + re := regexp.MustCompile("[ab]+") + t.Run("passes", func(t *testing.T) { + err := StringDenyRegexp(re).Validate("cd") + assert.NoError(t, err) + }) + t.Run("fails", func(t *testing.T) { + err := StringDenyRegexp(re).Validate("ab") + assert.EqualError(t, err, "string must not match regular expression: '[ab]+'") + assert.True(t, HasErrorCode(err, ErrorCodeStringDenyRegexp)) + }) + t.Run("examples output", func(t *testing.T) { + err := StringDenyRegexp(re, "ab", "a", "b").Validate("ab") + assert.EqualError(t, err, "string must not match regular expression: '[ab]+' (e.g. 'ab', 'a', 'b')") + assert.True(t, HasErrorCode(err, ErrorCodeStringDenyRegexp)) + }) +} + +func TestStringIsDNSSubdomain(t *testing.T) { + t.Run("passes", func(t *testing.T) { + for _, input := range []string{ + "test", + "s", + "test-this", + "test-1-this", + "test1-this", + "123", + strings.Repeat("l", 63), + } { + err := StringIsDNSSubdomain().Validate(input) + assert.NoError(t, err) + } + }) + t.Run("fails", func(t *testing.T) { + for _, input := range []string{ + "tesT", + "", + strings.Repeat("l", 64), + "test?", + "test this", + "1_2", + "LOL", + } { + err := StringIsDNSSubdomain().Validate(input) + assert.Error(t, err) + for _, e := range err.(ruleSetError) { + assert.True(t, HasErrorCode(e, ErrorCodeStringIsDNSSubdomain)) + } + } + }) +} + +func TestStringASCII(t *testing.T) { + t.Run("passes", func(t *testing.T) { + for _, input := range []string{ + "foobar", + "0987654321", + "test@example.com", + "1234abcDEF", + "", + } { + err := StringASCII().Validate(input) + assert.NoError(t, err) + } + }) + t.Run("fails", func(t *testing.T) { + for _, input := range []string{ + // cspell:disable + "foobar", + "xyz098", + "123456", + "カタカナ", + // cspell:enable + } { + err := StringASCII().Validate(input) + assert.Error(t, err) + assert.True(t, HasErrorCode(err, ErrorCodeStringASCII)) + } + }) +} + +func TestStringUUID(t *testing.T) { + t.Run("passes", func(t *testing.T) { + for _, input := range []string{ + "00000000-0000-0000-0000-000000000000", + "e190c630-8873-11ee-b9d1-0242ac120002", + "79258D24-01A7-47E5-ACBB-7E762DE52298", + } { + err := StringUUID().Validate(input) + assert.NoError(t, err) + } + }) + t.Run("fails", func(t *testing.T) { + for _, input := range []string{ + // cspell:disable + "foobar", + "0987654321", + "AXAXAXAX-AAAA-AAAA-AAAA-AAAAAAAAAAAA", + "00000000-0000-0000-0000-0000000000", + // cspell:enable + } { + err := StringUUID().Validate(input) + assert.Error(t, err) + assert.True(t, HasErrorCode(err, ErrorCodeStringUUID)) + } + }) +} + +func TestStringDescription(t *testing.T) { + t.Run("passes", func(t *testing.T) { + err := StringDescription().Validate(strings.Repeat("l", 1050)) + assert.NoError(t, err) + }) + t.Run("fails", func(t *testing.T) { + err := StringDescription().Validate(strings.Repeat("l", 1051)) + assert.Error(t, err) + assert.True(t, HasErrorCode(err, ErrorCodeStringDescription)) + }) +} + +func TestStringIsURL(t *testing.T) { + t.Run("passes", func(t *testing.T) { + for _, input := range validURLs { + err := StringURL().Validate(input) + assert.NoError(t, err) + } + }) + t.Run("fails", func(t *testing.T) { + for _, input := range invalidURLs { + err := StringURL().Validate(input) + assert.Error(t, err) + assert.True(t, HasErrorCode(err, ErrorCodeStringURL)) + } + }) +} + +func TestStringJSON(t *testing.T) { + t.Run("passes", func(t *testing.T) { + err := StringJSON().Validate(`{"foo": "bar"}`) + assert.NoError(t, err) + }) + t.Run("fails", func(t *testing.T) { + err := StringJSON().Validate(`{]}`) + assert.Error(t, err) + assert.True(t, HasErrorCode(err, ErrorCodeStringJSON)) + }) +} + +func TestStringContains(t *testing.T) { + t.Run("passes", func(t *testing.T) { + err := StringContains("th", "is").Validate("this") + assert.NoError(t, err) + }) + t.Run("fails", func(t *testing.T) { + err := StringContains("th", "ht").Validate("one") + assert.Error(t, err) + assert.EqualError(t, err, "string must contain the following substrings: 'th', 'ht'") + assert.True(t, HasErrorCode(err, ErrorCodeStringContains)) + }) +} + +func TestStringStartsWith(t *testing.T) { + t.Run("passes", func(t *testing.T) { + for _, prefixes := range [][]string{ + {"th"}, + {"is", "th"}, + } { + err := StringStartsWith(prefixes...).Validate("this") + assert.NoError(t, err) + } + }) + t.Run("fails with single prefix", func(t *testing.T) { + err := StringStartsWith("th").Validate("one") + assert.Error(t, err) + assert.EqualError(t, err, "string must start with 'th' prefix") + assert.True(t, HasErrorCode(err, ErrorCodeStringStartsWith)) + }) + t.Run("fails with multiple prefixes", func(t *testing.T) { + err := StringStartsWith("th", "ht").Validate("one") + assert.Error(t, err) + assert.EqualError(t, err, "string must start with one of the following prefixes: 'th', 'ht'") + assert.True(t, HasErrorCode(err, ErrorCodeStringStartsWith)) + }) +} diff --git a/pkg/govy/test_data/expected_pod_plan.json b/pkg/govy/test_data/expected_pod_plan.json new file mode 100644 index 0000000..c33b4e6 --- /dev/null +++ b/pkg/govy/test_data/expected_pod_plan.json @@ -0,0 +1,164 @@ +[ + { + "path": "$.apiVersion", + "type": "string", + "rules": [ + { + "description": "must be one of: v1, v2", + "errorCode": "one_of" + } + ] + }, + { + "path": "$.kind", + "type": "string", + "rules": [ + { + "description": "should be equal to 'Pod'", + "errorCode": "equal_to" + } + ] + }, + { + "path": "$.metadata.annotations", + "type": "Annotations", + "package": "github.com/nobl9/nobl9-go/internal/validation", + "rules": [ + { + "description": "length must be less than or equal to 10", + "errorCode": "map_max_length" + } + ] + }, + { + "path": "$.metadata.annotations.*", + "type": "MapItem[string,string]", + "package": "github.com/nobl9/nobl9-go/internal/validation", + "rules": [ + { + "description": "key and value must not be equal" + } + ] + }, + { + "path": "$.metadata.labels", + "type": "Labels", + "package": "github.com/nobl9/nobl9-go/internal/validation", + "rules": [ + { + "description": "length must be less than or equal to 10", + "errorCode": "map_max_length" + } + ] + }, + { + "path": "$.metadata.labels.*", + "type": "string", + "rules": [ + { + "description": "length must be less than or equal to 120", + "errorCode": "string_max_length" + } + ] + }, + { + "path": "$.metadata.labels.~", + "type": "string", + "rules": [ + { + "description": "length must be between 1 and 63", + "errorCode": "string_length" + }, + { + "description": "string must match regular expression: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$' (e.g. 'my-name', '123-abc')", + "details": "a DNS-1123 compliant name must consist of lower case alphanumeric characters or '-', and must start and end with an alphanumeric character", + "errorCode": "string_match_regexp" + } + ] + }, + { + "path": "$.metadata.name", + "type": "string", + "rules": [ + { + "description": "string cannot be empty", + "errorCode": "string_not_empty" + } + ] + }, + { + "path": "$.metadata.namespace", + "type": "string", + "rules": [ + { + "description": "string cannot be empty", + "errorCode": "string_not_empty" + } + ] + }, + { + "path": "$.spec.containers", + "type": "[]Container", + "package": "github.com/nobl9/nobl9-go/internal/validation", + "rules": [ + { + "description": "length must be less than or equal to 10", + "errorCode": "slice_max_length" + }, + { + "description": "elements must be unique", + "errorCode": "slice_unique" + } + ] + }, + { + "path": "$.spec.containers[*].env", + "type": "[]EnvVar", + "package": "github.com/nobl9/nobl9-go/internal/validation" + }, + { + "path": "$.spec.containers[*].env[*]", + "type": "EnvVar", + "package": "github.com/nobl9/nobl9-go/internal/validation", + "rules": [ + { + "description": "custom error!" + } + ] + }, + { + "path": "$.spec.containers[*].image", + "type": "string", + "rules": [ + { + "description": "string cannot be empty", + "errorCode": "string_not_empty" + } + ] + }, + { + "path": "$.spec.containers[*].name", + "type": "string", + "rules": [ + { + "description": "length must be between 1 and 63", + "errorCode": "string_length" + }, + { + "description": "string must match regular expression: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$' (e.g. 'my-name', '123-abc')", + "details": "a DNS-1123 compliant name must consist of lower case alphanumeric characters or '-', and must start and end with an alphanumeric character", + "errorCode": "string_match_regexp" + } + ] + }, + { + "path": "$.spec.dnsPolicy", + "type": "string", + "rules": [ + { + "description": "must be one of: ClusterFirst, Default", + "errorCode": "one_of" + } + ] + } +] diff --git a/pkg/govy/test_data/multi_error.txt b/pkg/govy/test_data/multi_error.txt new file mode 100644 index 0000000..239c60d --- /dev/null +++ b/pkg/govy/test_data/multi_error.txt @@ -0,0 +1,3 @@ +- this is just a test! +- another error... +- that is just fatal. \ No newline at end of file diff --git a/pkg/govy/test_data/property_error_map.txt b/pkg/govy/test_data/property_error_map.txt new file mode 100644 index 0000000..ee7589b --- /dev/null +++ b/pkg/govy/test_data/property_error_map.txt @@ -0,0 +1,4 @@ +'metadata.name' with value '{"this":"that"}': + - what a shame this happened + - this is outrageous... + - here's another error \ No newline at end of file diff --git a/pkg/govy/test_data/property_error_no_name.txt b/pkg/govy/test_data/property_error_no_name.txt new file mode 100644 index 0000000..381d0eb --- /dev/null +++ b/pkg/govy/test_data/property_error_no_name.txt @@ -0,0 +1,3 @@ +- what a shame this happened +- this is outrageous... +- here's another error \ No newline at end of file diff --git a/pkg/govy/test_data/property_error_slice.txt b/pkg/govy/test_data/property_error_slice.txt new file mode 100644 index 0000000..26aac95 --- /dev/null +++ b/pkg/govy/test_data/property_error_slice.txt @@ -0,0 +1,4 @@ +'metadata.name' with value '["this","that"]': + - what a shame this happened + - this is outrageous... + - here's another error \ No newline at end of file diff --git a/pkg/govy/test_data/property_error_string.txt b/pkg/govy/test_data/property_error_string.txt new file mode 100644 index 0000000..31f61ad --- /dev/null +++ b/pkg/govy/test_data/property_error_string.txt @@ -0,0 +1,4 @@ +'metadata.name' with value 'default': + - what a shame this happened + - this is outrageous... + - here's another error \ No newline at end of file diff --git a/pkg/govy/test_data/property_error_stringer_pointer.txt b/pkg/govy/test_data/property_error_stringer_pointer.txt new file mode 100644 index 0000000..93a48ad --- /dev/null +++ b/pkg/govy/test_data/property_error_stringer_pointer.txt @@ -0,0 +1,4 @@ +'metadata.name' with value 'this_that': + - what a shame this happened + - this is outrageous... + - here's another error \ No newline at end of file diff --git a/pkg/govy/test_data/property_error_stringer_with_json_tags.txt b/pkg/govy/test_data/property_error_stringer_with_json_tags.txt new file mode 100644 index 0000000..c2498ab --- /dev/null +++ b/pkg/govy/test_data/property_error_stringer_with_json_tags.txt @@ -0,0 +1,4 @@ +'metadata.name' with value '{"this":"this","THAT":"that"}': + - what a shame this happened + - this is outrageous... + - here's another error \ No newline at end of file diff --git a/pkg/govy/test_data/property_error_stringer_without_json_tags.txt b/pkg/govy/test_data/property_error_stringer_without_json_tags.txt new file mode 100644 index 0000000..93a48ad --- /dev/null +++ b/pkg/govy/test_data/property_error_stringer_without_json_tags.txt @@ -0,0 +1,4 @@ +'metadata.name' with value 'this_that': + - what a shame this happened + - this is outrageous... + - here's another error \ No newline at end of file diff --git a/pkg/govy/test_data/property_error_struct.txt b/pkg/govy/test_data/property_error_struct.txt new file mode 100644 index 0000000..c2498ab --- /dev/null +++ b/pkg/govy/test_data/property_error_struct.txt @@ -0,0 +1,4 @@ +'metadata.name' with value '{"this":"this","THAT":"that"}': + - what a shame this happened + - this is outrageous... + - here's another error \ No newline at end of file diff --git a/pkg/govy/test_data/property_error_transform.txt b/pkg/govy/test_data/property_error_transform.txt new file mode 100644 index 0000000..23bc283 --- /dev/null +++ b/pkg/govy/test_data/property_error_transform.txt @@ -0,0 +1,2 @@ +- 'prop' with value '123z': + - strconv.Atoi: parsing "123z": invalid syntax \ No newline at end of file diff --git a/pkg/govy/test_data/property_error_transform_with_hidden_value.txt b/pkg/govy/test_data/property_error_transform_with_hidden_value.txt new file mode 100644 index 0000000..36115a7 --- /dev/null +++ b/pkg/govy/test_data/property_error_transform_with_hidden_value.txt @@ -0,0 +1,2 @@ +- 'prop': + - strconv.Atoi: parsing "[hidden]": invalid syntax \ No newline at end of file diff --git a/pkg/govy/test_data/validator_error_no_name.txt b/pkg/govy/test_data/validator_error_no_name.txt new file mode 100644 index 0000000..b5d65ed --- /dev/null +++ b/pkg/govy/test_data/validator_error_no_name.txt @@ -0,0 +1,5 @@ +Validation has failed for the following properties: + - 'this' with value '123': + - this is an error + - 'that': + - that is an error \ No newline at end of file diff --git a/pkg/govy/test_data/validator_error_prop_no_name.txt b/pkg/govy/test_data/validator_error_prop_no_name.txt new file mode 100644 index 0000000..31c548c --- /dev/null +++ b/pkg/govy/test_data/validator_error_prop_no_name.txt @@ -0,0 +1,4 @@ +Validation has failed for the following properties: + - no name + - 'that': + - that is an error \ No newline at end of file diff --git a/pkg/govy/test_data/validator_error_with_name.txt b/pkg/govy/test_data/validator_error_with_name.txt new file mode 100644 index 0000000..37610b2 --- /dev/null +++ b/pkg/govy/test_data/validator_error_with_name.txt @@ -0,0 +1,5 @@ +Validation for Teacher has failed for the following properties: + - 'this' with value '123': + - this is an error + - 'that': + - that is an error \ No newline at end of file diff --git a/pkg/govy/unique.go b/pkg/govy/unique.go new file mode 100644 index 0000000..9c058f0 --- /dev/null +++ b/pkg/govy/unique.go @@ -0,0 +1,44 @@ +package validation + +import ( + "fmt" + "strings" +) + +// HashFunction accepts a value and returns a comparable hash. +type HashFunction[V any, H comparable] func(v V) H + +// SelfHashFunc returns a HashFunction which returns it's input value as a hash itself. +// The value must be comparable. +func SelfHashFunc[H comparable]() HashFunction[H, H] { + return func(v H) H { return v } +} + +// SliceUnique validates that a slice contains unique elements based on a provided HashFunction. +// You can optionally specify constraints which will be included in the error message to further +// clarify the reason for breaking uniqueness. +func SliceUnique[S []V, V any, H comparable](hashFunc HashFunction[V, H], constraints ...string) SingleRule[S] { + return NewSingleRule(func(slice S) error { + unique := make(map[H]int) + for i := range slice { + hash := hashFunc(slice[i]) + if j, ok := unique[hash]; ok { + errMsg := fmt.Sprintf("elements are not unique, index %d collides with index %d", j, i) + if len(constraints) > 0 { + errMsg += " based on constraints: " + strings.Join(constraints, ", ") + } + return fmt.Errorf(errMsg) + } + unique[hash] = i + } + return nil + }). + WithErrorCode(ErrorCodeSliceUnique). + WithDescription(func() string { + msg := "elements must be unique" + if len(constraints) > 0 { + msg += " according to the following constraints: " + strings.Join(constraints, ", ") + } + return msg + }()) +} diff --git a/pkg/govy/unique_test.go b/pkg/govy/unique_test.go new file mode 100644 index 0000000..657672b --- /dev/null +++ b/pkg/govy/unique_test.go @@ -0,0 +1,37 @@ +package validation + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSliceUnique(t *testing.T) { + t.Run("passes", func(t *testing.T) { + err := SliceUnique(SelfHashFunc[string]()).Validate([]string{"a", "b", "c"}) + assert.NoError(t, err) + }) + t.Run("fails", func(t *testing.T) { + err := SliceUnique(SelfHashFunc[string]()).Validate([]string{"a", "b", "c", "b"}) + require.Error(t, err) + assert.EqualError(t, err, "elements are not unique, index 1 collides with index 3") + assert.True(t, HasErrorCode(err, ErrorCodeSliceUnique)) + }) + t.Run("fails with constraint", func(t *testing.T) { + err := SliceUnique(SelfHashFunc[string](), "values must be unique"). + Validate([]string{"a", "b", "c", "b"}) + require.Error(t, err) + assert.EqualError(t, err, "elements are not unique, index 1 collides with index 3 "+ + "based on constraints: values must be unique") + assert.True(t, HasErrorCode(err, ErrorCodeSliceUnique)) + }) + t.Run("fails with constraints", func(t *testing.T) { + err := SliceUnique(SelfHashFunc[string](), "constraint 1", "constraint 2"). + Validate([]string{"a", "b", "c", "b"}) + require.Error(t, err) + assert.EqualError(t, err, "elements are not unique, index 1 collides with index 3 "+ + "based on constraints: constraint 1, constraint 2") + assert.True(t, HasErrorCode(err, ErrorCodeSliceUnique)) + }) +} diff --git a/pkg/govy/url.go b/pkg/govy/url.go new file mode 100644 index 0000000..88b21d8 --- /dev/null +++ b/pkg/govy/url.go @@ -0,0 +1,25 @@ +package validation + +import ( + "net/url" + + "github.com/pkg/errors" +) + +func URL() SingleRule[*url.URL] { + return NewSingleRule(validateURL). + WithErrorCode(ErrorCodeURL). + WithDescription(urlDescription) +} + +const urlDescription = "valid URL must have a scheme (e.g. https://) and contain either host, fragment or opaque data" + +func validateURL(u *url.URL) error { + if u.Scheme == "" { + return errors.New("valid URL must have a scheme (e.g. https://)") + } + if u.Host == "" && u.Fragment == "" && u.Opaque == "" { + return errors.New("valid URL must contain either host, fragment or opaque data") + } + return nil +} diff --git a/pkg/govy/url_test.go b/pkg/govy/url_test.go new file mode 100644 index 0000000..68e9590 --- /dev/null +++ b/pkg/govy/url_test.go @@ -0,0 +1,71 @@ +package validation + +import ( + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var validURLs = []string{ + "http://foo.bar#com", + "http://foobar.com", + "https://foobar.com", + "http://foobar.coffee/", + "http://foobar.中文网/", + "http://foobar.org/", + "http://foobar.org:8080/", + "ftp://foobar.ua/", + "http://user:pass@www.foobar.com/", + "http://127.0.0.1/", + "http://duckduckgo.com/?q=%2F", + "http://localhost:3000/", + "http://foobar.com/?foo=bar#baz=qux", + "http://foobar.com?foo=bar", + "http://www.xn--froschgrn-x9a.net/", + "xyz://foobar.com", + "rtmp://foobar.com", + "http://www.foo_bar.com/", + "http://localhost:3000/", + "http://foobar.com/#baz", + "http://foobar.com#baz=qux", + "http://foobar.com/t$-_.+!*\\'(),", + "http://www.foobar.com/~foobar", + "http://www.-foobar.com/", + "http://www.foo---bar.com/", + "mailto:someone@example.com", + "irc://irc.server.org/channel", + "irc://#channel@network", +} + +var invalidURLs = []string{ + "foobar.com", + "", + "invalid.", + ".com", + "/abs/test/dir", + "./rel/test/dir", + "irc:", + "http://", +} + +func TestURL(t *testing.T) { + t.Run("passes", func(t *testing.T) { + for _, input := range validURLs { + u, err := url.Parse(input) + require.NoError(t, err) + err = URL().Validate(u) + assert.NoError(t, err) + } + }) + t.Run("fails", func(t *testing.T) { + for _, input := range invalidURLs { + u, err := url.Parse(input) + require.NoError(t, err) + err = URL().Validate(u) + require.Error(t, err) + assert.True(t, HasErrorCode(err, ErrorCodeURL)) + } + }) +} diff --git a/pkg/govy/validator.go b/pkg/govy/validator.go new file mode 100644 index 0000000..8a7f904 --- /dev/null +++ b/pkg/govy/validator.go @@ -0,0 +1,65 @@ +package validation + +type validatorI[S any] interface { + Validate(s S) *ValidatorError +} + +type propertyRulesI[S any] interface { + Validate(s S) PropertyErrors +} + +// New creates a new [Validator] aggregating the provided property rules. +func New[S any](props ...propertyRulesI[S]) Validator[S] { + return Validator[S]{props: props} +} + +// Validator is the top level validation entity. +// It serves as an aggregator for [PropertyRules]. +type Validator[S any] struct { + props []propertyRulesI[S] + name string + + predicateMatcher[S] +} + +// WithName when a rule fails will pass the provided name to [ValidatorError.WithName]. +func (v Validator[S]) WithName(name string) Validator[S] { + v.name = name + return v +} + +// When defines accepts predicates which will be evaluated BEFORE [Validator] validates ANY rules. +func (v Validator[S]) When(predicate Predicate[S], opts ...WhenOptions) Validator[S] { + v.predicateMatcher = v.when(predicate, opts...) + return v +} + +// Validate will first evaluate predicates before validating any rules. +// If any predicate does not pass the validation won't be executed (returns nil). +// All errors returned by property rules will be aggregated and wrapped in [ValidatorError]. +func (v Validator[S]) Validate(st S) *ValidatorError { + if !v.matchPredicates(st) { + return nil + } + var allErrors PropertyErrors + for _, rules := range v.props { + if errs := rules.Validate(st); len(errs) > 0 { + allErrors = append(allErrors, errs...) + } + } + if len(allErrors) != 0 { + return NewValidatorError(allErrors).WithName(v.name) + } + return nil +} + +func (v Validator[S]) plan(path planBuilder) { + for _, predicate := range v.predicates { + path.rulePlan.Conditions = append(path.rulePlan.Conditions, predicate.description) + } + for _, rules := range v.props { + if p, ok := rules.(planner); ok { + p.plan(path) + } + } +} diff --git a/pkg/govy/validator_test.go b/pkg/govy/validator_test.go new file mode 100644 index 0000000..8a90b1a --- /dev/null +++ b/pkg/govy/validator_test.go @@ -0,0 +1,100 @@ +package validation + +import ( + "testing" + + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidator(t *testing.T) { + t.Run("no errors", func(t *testing.T) { + r := New( + For(func(m mockValidatorStruct) string { return "test" }). + WithName("test"). + Rules(NewSingleRule(func(v string) error { return nil })), + ) + errs := r.Validate(mockValidatorStruct{}) + assert.Nil(t, errs) + }) + + t.Run("errors", func(t *testing.T) { + err1 := errors.New("1") + err2 := errors.New("2") + r := New( + For(func(m mockValidatorStruct) string { return "test" }). + WithName("test"). + Rules(NewSingleRule(func(v string) error { return nil })), + For(func(m mockValidatorStruct) string { return "name" }). + WithName("test.name"). + Rules(NewSingleRule(func(v string) error { return err1 })), + For(func(m mockValidatorStruct) string { return "display" }). + WithName("test.display"). + Rules(NewSingleRule(func(v string) error { return err2 })), + ) + err := r.Validate(mockValidatorStruct{}) + require.Len(t, err.Errors, 2) + assert.Equal(t, &ValidatorError{Errors: PropertyErrors{ + &PropertyError{ + PropertyName: "test.name", + PropertyValue: "name", + Errors: []*RuleError{{Message: err1.Error()}}, + }, + &PropertyError{ + PropertyName: "test.display", + PropertyValue: "display", + Errors: []*RuleError{{Message: err2.Error()}}, + }, + }}, err) + }) +} + +func TestValidatorWhen(t *testing.T) { + t.Run("when condition is not met, don't validate", func(t *testing.T) { + r := New( + For(func(m mockValidatorStruct) string { return "test" }). + WithName("test"). + Rules(NewSingleRule(func(v string) error { return errors.New("test") })), + ). + When(func(validatorStruct mockValidatorStruct) bool { return false }) + + errs := r.Validate(mockValidatorStruct{}) + assert.Nil(t, errs) + }) + t.Run("when condition is met, validate", func(t *testing.T) { + r := New( + For(func(m mockValidatorStruct) string { return "test" }). + WithName("test"). + Rules(NewSingleRule(func(v string) error { return errors.New("test") })), + ). + When(func(validatorStruct mockValidatorStruct) bool { return true }) + + errs := r.Validate(mockValidatorStruct{}) + require.Len(t, errs.Errors, 1) + assert.Equal(t, &ValidatorError{Errors: PropertyErrors{ + &PropertyError{ + PropertyName: "test", + PropertyValue: "test", + Errors: []*RuleError{{Message: "test"}}, + }, + }}, errs) + }) +} + +func TestValidatorWithName(t *testing.T) { + r := New( + For(func(m mockValidatorStruct) string { return "test" }). + WithName("test"). + Rules(NewSingleRule(func(v string) error { return errors.New("test") })), + ).WithName("validator") + + err := r.Validate(mockValidatorStruct{}) + assert.EqualError(t, err, `Validation for validator has failed for the following properties: + - 'test' with value 'test': + - test`) +} + +type mockValidatorStruct struct { + Field string +}