diff --git a/README.md b/README.md index df0649f6..28f7e159 100644 --- a/README.md +++ b/README.md @@ -262,6 +262,8 @@ validate := validator.New(validator.WithRequiredStructEnabled()) | excluded_without | Excluded Without | | excluded_without_all | Excluded Without All | | unique | Unique | +| validateFn | Verify if the method `Validate() error` does not return an error (or any specified method) | + #### Aliases: | Tag | Description | diff --git a/_examples/validate_fn/go.mod b/_examples/validate_fn/go.mod new file mode 100644 index 00000000..b9077c8c --- /dev/null +++ b/_examples/validate_fn/go.mod @@ -0,0 +1,18 @@ +module github.com/peczenyj/validator/_examples/validate_fn + +go 1.20 + +replace github.com/go-playground/validator/v10 => ../../../validator + +require github.com/go-playground/validator/v10 v10.26.0 + +require ( + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + golang.org/x/crypto v0.33.0 // indirect + golang.org/x/net v0.34.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect +) diff --git a/_examples/validate_fn/go.sum b/_examples/validate_fn/go.sum new file mode 100644 index 00000000..3533f3e0 --- /dev/null +++ b/_examples/validate_fn/go.sum @@ -0,0 +1,21 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/_examples/validate_fn/main.go b/_examples/validate_fn/main.go new file mode 100644 index 00000000..bf367bb5 --- /dev/null +++ b/_examples/validate_fn/main.go @@ -0,0 +1,52 @@ +package main + +import ( + "errors" + "fmt" + + "github.com/go-playground/validator/v10" +) + +type Enum uint8 + +const ( + Zero Enum = iota + One + Two +) + +func (e Enum) NotZero() bool { + return e != Zero +} + +func (e *Enum) Validate() error { + if e == nil { + return errors.New("can't be nil") + } + + return nil +} + +type Struct struct { + Foo *Enum `validate:"validateFn"` // uses Validate() error by default + Bar Enum `validate:"validateFn=NotZero"` // uses NotZero() bool +} + +func main() { + validate := validator.New() + + var x Struct + + if err := validate.Struct(x); err != nil { + fmt.Printf("Expected Err(s):\n%+v\n", err) + } + + x = Struct{ + Foo: new(Enum), + Bar: One, + } + + if err := validate.Struct(x); err != nil { + fmt.Printf("Unexpected Err(s):\n%+v\n", err) + } +} diff --git a/baked_in.go b/baked_in.go index fe361217..eb7a349f 100644 --- a/baked_in.go +++ b/baked_in.go @@ -2,10 +2,12 @@ package validator import ( "bytes" + "cmp" "context" "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "io/fs" "net" @@ -244,6 +246,7 @@ var ( "cron": isCron, "spicedb": isSpiceDB, "ein": isEIN, + "validateFn": isValidateFn, } ) @@ -3046,3 +3049,61 @@ func isEIN(fl FieldLevel) bool { return einRegex().MatchString(field.String()) } + +func isValidateFn(fl FieldLevel) bool { + const defaultParam = `Validate` + + field := fl.Field() + validateFn := cmp.Or(fl.Param(), defaultParam) + + ok, err := tryCallValidateFn(field, validateFn) + if err != nil { + // error can be used in some log + return false + } + + return ok +} + +var ( + errMethodNotFound = errors.New(`method not found`) + errMethodReturnNoValues = errors.New(`method return o values (void)`) + errMethodReturnInvalidType = errors.New(`method should return invalid type`) +) + +func tryCallValidateFn(field reflect.Value, validateFn string) (bool, error) { + method := field.MethodByName(validateFn) + if !method.IsValid() { + method = field.Addr().MethodByName(validateFn) + } + + if !method.IsValid() { + return false, fmt.Errorf("unable to call %q on type %q: %w", + validateFn, field.Type().String(), errMethodNotFound) + } + + returnValues := method.Call([]reflect.Value{}) + if len(returnValues) == 0 { + return false, fmt.Errorf("unable to use result of method %q on type %q: %w", + validateFn, field.Type().String(), errMethodReturnNoValues) + } + + firstReturnValue := returnValues[0] + + switch firstReturnValue.Kind() { + case reflect.Bool: + return firstReturnValue.Bool(), nil + case reflect.Interface: + errorType := reflect.TypeOf((*error)(nil)).Elem() + + if firstReturnValue.Type().Implements(errorType) { + return firstReturnValue.IsNil(), nil + } + + return false, fmt.Errorf("unable to use result of method %q on type %q: %w (got interface %v expect error)", + validateFn, field.Type().String(), errMethodReturnInvalidType, firstReturnValue.Type().String()) + default: + return false, fmt.Errorf("unable to use result of method %q on type %q: %w (got %v expect error or bool)", + validateFn, field.Type().String(), errMethodReturnInvalidType, firstReturnValue.Type().String()) + } +} diff --git a/doc.go b/doc.go index e7a241fb..91bace77 100644 --- a/doc.go +++ b/doc.go @@ -756,6 +756,20 @@ in a field of the struct specified via a parameter. // For slices of struct: Usage: unique=field +# ValidateFn + +This validates that an object responds to a method that can return error or bool. +By default it expects an interface `Validate() error` and check that the method +does not return an error. Other methods can be specified using two signatures: +If the method returns an error, it check if the return value is nil. +If the method returns a boolean, it checks if the value is true. + + // to use the default method Validate() error + Usage: validateFn + + // to use the custom method IsValid() bool (or error) + Usage: validateFn=IsValid + # Alpha Only This validates that a string value contains ASCII alpha characters only diff --git a/translations/en/en.go b/translations/en/en.go index d0161c73..9458edc8 100644 --- a/translations/en/en.go +++ b/translations/en/en.go @@ -1484,6 +1484,11 @@ func RegisterDefaultTranslations(v *validator.Validate, trans ut.Translator) (er translation: "{0} must be a valid cve identifier", override: false, }, + { + tag: "validateFn", + translation: "{0} must be a valid object", + override: false, + }, } for _, t := range translations { diff --git a/translations/en/en_test.go b/translations/en/en_test.go index 7ae4d002..44ce09a8 100644 --- a/translations/en/en_test.go +++ b/translations/en/en_test.go @@ -10,6 +10,10 @@ import ( "github.com/go-playground/validator/v10" ) +type Foo struct{} + +func (Foo) IsBar() bool { return false } + func TestTranslations(t *testing.T) { eng := english.New() uni := ut.New(eng, eng) @@ -181,6 +185,7 @@ func TestTranslations(t *testing.T) { CveString string `validate:"cve"` MinDuration time.Duration `validate:"min=1h30m,max=2h"` MaxDuration time.Duration `validate:"min=1h30m,max=2h"` + ValidateFn Foo `validate:"validateFn=IsBar"` } var test Test @@ -805,6 +810,10 @@ func TestTranslations(t *testing.T) { ns: "Test.MaxDuration", expected: "MaxDuration must be 2h or less", }, + { + ns: "Test.ValidateFn", + expected: "ValidateFn must be a valid object", + }, } for _, tt := range tests { diff --git a/translations/pt_BR/pt_BR.go b/translations/pt_BR/pt_BR.go index 0e438487..27cb2a5e 100644 --- a/translations/pt_BR/pt_BR.go +++ b/translations/pt_BR/pt_BR.go @@ -1292,6 +1292,11 @@ func RegisterDefaultTranslations(v *validator.Validate, trans ut.Translator) (er translation: "{0} deve ser um identificador cve válido", override: false, }, + { + tag: "validateFn", + translation: "{0} deve ser um objeto válido", + override: false, + }, } for _, t := range translations { diff --git a/translations/pt_BR/pt_BR_test.go b/translations/pt_BR/pt_BR_test.go index aee97d8d..e5880d97 100644 --- a/translations/pt_BR/pt_BR_test.go +++ b/translations/pt_BR/pt_BR_test.go @@ -10,6 +10,10 @@ import ( "github.com/go-playground/validator/v10" ) +type Foo struct{} + +func (Foo) IsBar() bool { return false } + func TestTranslations(t *testing.T) { ptbr := brazilian_portuguese.New() uni := ut.New(ptbr, ptbr) @@ -142,6 +146,7 @@ func TestTranslations(t *testing.T) { BooleanString string `validate:"boolean"` Image string `validate:"image"` CveString string `validate:"cve"` + ValidateFn Foo `validate:"validateFn=IsBar"` } var test Test @@ -640,6 +645,10 @@ func TestTranslations(t *testing.T) { ns: "Test.CveString", expected: "CveString deve ser um identificador cve válido", }, + { + ns: "Test.ValidateFn", + expected: "ValidateFn deve ser um objeto válido", + }, } for _, tt := range tests { diff --git a/validator_instance.go b/validator_instance.go index e68a8703..9362cd73 100644 --- a/validator_instance.go +++ b/validator_instance.go @@ -231,23 +231,6 @@ func (v *Validate) RegisterValidationCtx(tag string, fn FuncCtx, callValidationE return v.registerValidation(tag, fn, false, nilCheckable) } -func (v *Validate) registerValidation(tag string, fn FuncCtx, bakedIn bool, nilCheckable bool) error { - if len(tag) == 0 { - return errors.New("function Key cannot be empty") - } - - if fn == nil { - return errors.New("function cannot be empty") - } - - _, ok := restrictedTags[tag] - if !bakedIn && (ok || strings.ContainsAny(tag, restrictedTagChars)) { - panic(fmt.Sprintf(restrictedTagErr, tag)) - } - v.validations[tag] = internalValidationFuncWrapper{fn: fn, runValidationOnNil: nilCheckable} - return nil -} - // RegisterAlias registers a mapping of a single validation tag that // defines a common or complex set of validation(s) to simplify adding validation // to structs. @@ -697,3 +680,20 @@ func (v *Validate) VarWithValueCtx(ctx context.Context, field interface{}, other v.pool.Put(vd) return } + +func (v *Validate) registerValidation(tag string, fn FuncCtx, bakedIn bool, nilCheckable bool) error { + if len(tag) == 0 { + return errors.New("function Key cannot be empty") + } + + if fn == nil { + return errors.New("function cannot be empty") + } + + _, ok := restrictedTags[tag] + if !bakedIn && (ok || strings.ContainsAny(tag, restrictedTagChars)) { + panic(fmt.Sprintf(restrictedTagErr, tag)) + } + v.validations[tag] = internalValidationFuncWrapper{fn: fn, runValidationOnNil: nilCheckable} + return nil +} diff --git a/validator_test.go b/validator_test.go index 51bc44c6..e8323202 100644 --- a/validator_test.go +++ b/validator_test.go @@ -7,6 +7,7 @@ import ( "database/sql/driver" "encoding/base64" "encoding/json" + "errors" "fmt" "image" "image/jpeg" @@ -14148,3 +14149,109 @@ func TestPrivateFieldsStruct(t *testing.T) { Equal(t, len(errs), tc.errorNum) } } + +type NotRed struct { + Color string +} + +func (r *NotRed) Validate() error { + if r != nil && r.Color == "red" { + return errors.New("should not be red") + } + + return nil +} + +func (r NotRed) IsNotRed() bool { + return r.Color != "red" +} + +func TestValidateFn(t *testing.T) { + t.Run("using pointer", func(t *testing.T) { + validate := New() + + type Test struct { + String string + Inner *NotRed `validate:"validateFn"` + } + + var tt Test + + errs := validate.Struct(tt) + NotEqual(t, errs, nil) + + fe := errs.(ValidationErrors)[0] + Equal(t, fe.Field(), "Inner") + Equal(t, fe.Namespace(), "Test.Inner") + Equal(t, fe.Tag(), "validateFn") + + tt.Inner = &NotRed{Color: "blue"} + errs = validate.Struct(tt) + Equal(t, errs, nil) + + tt.Inner = &NotRed{Color: "red"} + errs = validate.Struct(tt) + NotEqual(t, errs, nil) + + fe = errs.(ValidationErrors)[0] + Equal(t, fe.Field(), "Inner") + Equal(t, fe.Namespace(), "Test.Inner") + Equal(t, fe.Tag(), "validateFn") + }) + + t.Run("using struct", func(t *testing.T) { + validate := New() + + type Test2 struct { + String string + Inner NotRed `validate:"validateFn"` + } + + var tt2 Test2 + + errs := validate.Struct(&tt2) + Equal(t, errs, nil) + + tt2.Inner = NotRed{Color: "blue"} + + errs = validate.Struct(&tt2) + Equal(t, errs, nil) + + tt2.Inner = NotRed{Color: "red"} + errs = validate.Struct(&tt2) + NotEqual(t, errs, nil) + + fe := errs.(ValidationErrors)[0] + Equal(t, fe.Field(), "Inner") + Equal(t, fe.Namespace(), "Test2.Inner") + Equal(t, fe.Tag(), "validateFn") + }) + + t.Run("using struct with custom function", func(t *testing.T) { + validate := New() + + type Test2 struct { + String string + Inner NotRed `validate:"validateFn=IsNotRed"` + } + + var tt2 Test2 + + errs := validate.Struct(&tt2) + Equal(t, errs, nil) + + tt2.Inner = NotRed{Color: "blue"} + + errs = validate.Struct(&tt2) + Equal(t, errs, nil) + + tt2.Inner = NotRed{Color: "red"} + errs = validate.Struct(&tt2) + NotEqual(t, errs, nil) + + fe := errs.(ValidationErrors)[0] + Equal(t, fe.Field(), "Inner") + Equal(t, fe.Namespace(), "Test2.Inner") + Equal(t, fe.Tag(), "validateFn") + }) +}