Skip to content

Commit 8032f40

Browse files
add support to tag validateFn (#1363)
## Fixes Or Enhances This Pull Requests adds a new tag called `isvalid` If the field is marked with the validator tag `isvalid`, the type must implement the interface `Validate() error` and the return must be nil to be considered `valid` A possible use case is: when dealing with Enumerations, the type can support a method `Validate() error` to check if the value is in specific the range defined. If we use [enumer](https://github.com/dmarkham/enumer) it generates a `IsA<Type>() bool` method that can be used to verify if the enumeration is valid or not, instead force the `oneof` tag (that needs to be always updated when we add one new value. I wrote a pull request to add a Validate method on enumerations [here](dmarkham/enumer#102) and the interface `Validate() error` seems pretty common. It may clash with existing tags that people may register, this is something that I don't know how to solve. **Make sure that you've checked the boxes below before you submit PR:** - [X] Tests exist or have been written that cover this particular change. @go-playground/validator-maintainers --------- Co-authored-by: nodivbyzero <[email protected]>
1 parent 0540a5e commit 8032f40

File tree

14 files changed

+480
-17
lines changed

14 files changed

+480
-17
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,8 @@ validate := validator.New(validator.WithRequiredStructEnabled())
262262
| excluded_without | Excluded Without |
263263
| excluded_without_all | Excluded Without All |
264264
| unique | Unique |
265+
| validateFn | Verify if the method `Validate() error` does not return an error (or any specified method) |
266+
265267

266268
#### Aliases:
267269
| Tag | Description |

_examples/validate_fn/enum_enumer.go

Lines changed: 86 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

_examples/validate_fn/go.mod

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
module github.com/peczenyj/validator/_examples/validate_fn
2+
3+
go 1.20
4+
5+
replace github.com/go-playground/validator/v10 => ../../../validator
6+
7+
require github.com/go-playground/validator/v10 v10.26.0
8+
9+
require (
10+
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
11+
github.com/go-playground/locales v0.14.1 // indirect
12+
github.com/go-playground/universal-translator v0.18.1 // indirect
13+
github.com/leodido/go-urn v1.4.0 // indirect
14+
golang.org/x/crypto v0.33.0 // indirect
15+
golang.org/x/net v0.34.0 // indirect
16+
golang.org/x/sys v0.30.0 // indirect
17+
golang.org/x/text v0.22.0 // indirect
18+
)

_examples/validate_fn/go.sum

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
2+
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
3+
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
4+
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
5+
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
6+
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
7+
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
8+
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
9+
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
10+
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
11+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
12+
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
13+
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
14+
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
15+
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
16+
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
17+
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
18+
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
19+
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
20+
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
21+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

_examples/validate_fn/main.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package main
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
7+
"github.com/go-playground/validator/v10"
8+
)
9+
10+
//go:generate enumer -type=Enum
11+
type Enum uint8
12+
13+
const (
14+
Zero Enum = iota
15+
One
16+
Two
17+
Three
18+
)
19+
20+
func (e *Enum) Validate() error {
21+
if e == nil {
22+
return errors.New("can't be nil")
23+
}
24+
25+
return nil
26+
}
27+
28+
type Struct struct {
29+
Foo *Enum `validate:"validateFn"` // uses Validate() error by default
30+
Bar Enum `validate:"validateFn=IsAEnum"` // uses IsAEnum() bool provided by enumer
31+
}
32+
33+
func main() {
34+
validate := validator.New()
35+
36+
var x Struct
37+
38+
x.Bar = Enum(64)
39+
40+
if err := validate.Struct(x); err != nil {
41+
fmt.Printf("Expected Err(s):\n%+v\n", err)
42+
}
43+
44+
x = Struct{
45+
Foo: new(Enum),
46+
Bar: One,
47+
}
48+
49+
if err := validate.Struct(x); err != nil {
50+
fmt.Printf("Unexpected Err(s):\n%+v\n", err)
51+
}
52+
}

baked_in.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ package validator
22

33
import (
44
"bytes"
5+
"cmp"
56
"context"
67
"crypto/sha256"
78
"encoding/hex"
89
"encoding/json"
10+
"errors"
911
"fmt"
1012
"io/fs"
1113
"net"
@@ -244,6 +246,7 @@ var (
244246
"cron": isCron,
245247
"spicedb": isSpiceDB,
246248
"ein": isEIN,
249+
"validateFn": isValidateFn,
247250
}
248251
)
249252

@@ -3046,3 +3049,60 @@ func isEIN(fl FieldLevel) bool {
30463049

30473050
return einRegex().MatchString(field.String())
30483051
}
3052+
3053+
func isValidateFn(fl FieldLevel) bool {
3054+
const defaultParam = `Validate`
3055+
3056+
field := fl.Field()
3057+
validateFn := cmp.Or(fl.Param(), defaultParam)
3058+
3059+
ok, err := tryCallValidateFn(field, validateFn)
3060+
if err != nil {
3061+
return false
3062+
}
3063+
3064+
return ok
3065+
}
3066+
3067+
var (
3068+
errMethodNotFound = errors.New(`method not found`)
3069+
errMethodReturnNoValues = errors.New(`method return o values (void)`)
3070+
errMethodReturnInvalidType = errors.New(`method should return invalid type`)
3071+
)
3072+
3073+
func tryCallValidateFn(field reflect.Value, validateFn string) (bool, error) {
3074+
method := field.MethodByName(validateFn)
3075+
if field.CanAddr() && !method.IsValid() {
3076+
method = field.Addr().MethodByName(validateFn)
3077+
}
3078+
3079+
if !method.IsValid() {
3080+
return false, fmt.Errorf("unable to call %q on type %q: %w",
3081+
validateFn, field.Type().String(), errMethodNotFound)
3082+
}
3083+
3084+
returnValues := method.Call([]reflect.Value{})
3085+
if len(returnValues) == 0 {
3086+
return false, fmt.Errorf("unable to use result of method %q on type %q: %w",
3087+
validateFn, field.Type().String(), errMethodReturnNoValues)
3088+
}
3089+
3090+
firstReturnValue := returnValues[0]
3091+
3092+
switch firstReturnValue.Kind() {
3093+
case reflect.Bool:
3094+
return firstReturnValue.Bool(), nil
3095+
case reflect.Interface:
3096+
errorType := reflect.TypeOf((*error)(nil)).Elem()
3097+
3098+
if firstReturnValue.Type().Implements(errorType) {
3099+
return firstReturnValue.IsNil(), nil
3100+
}
3101+
3102+
return false, fmt.Errorf("unable to use result of method %q on type %q: %w (got interface %v expect error)",
3103+
validateFn, field.Type().String(), errMethodReturnInvalidType, firstReturnValue.Type().String())
3104+
default:
3105+
return false, fmt.Errorf("unable to use result of method %q on type %q: %w (got %v expect error or bool)",
3106+
validateFn, field.Type().String(), errMethodReturnInvalidType, firstReturnValue.Type().String())
3107+
}
3108+
}

benchmarks_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package validator
33
import (
44
"bytes"
55
sql "database/sql/driver"
6+
"errors"
67
"testing"
78
"time"
89
)
@@ -1097,3 +1098,39 @@ func BenchmarkOneofParallel(b *testing.B) {
10971098
}
10981099
})
10991100
}
1101+
1102+
type T struct{}
1103+
1104+
func (*T) Validate() error { return errors.New("ops") }
1105+
1106+
func BenchmarkValidateFnSequencial(b *testing.B) {
1107+
validate := New()
1108+
1109+
type Test struct {
1110+
T T `validate:"validateFn"`
1111+
}
1112+
1113+
test := &Test{}
1114+
1115+
b.ResetTimer()
1116+
for n := 0; n < b.N; n++ {
1117+
_ = validate.Struct(test)
1118+
}
1119+
}
1120+
1121+
func BenchmarkValidateFnParallel(b *testing.B) {
1122+
validate := New()
1123+
1124+
type Test struct {
1125+
T T `validate:"validateFn"`
1126+
}
1127+
1128+
test := &Test{}
1129+
1130+
b.ResetTimer()
1131+
b.RunParallel(func(pb *testing.PB) {
1132+
for pb.Next() {
1133+
_ = validate.Struct(test)
1134+
}
1135+
})
1136+
}

doc.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -756,6 +756,20 @@ in a field of the struct specified via a parameter.
756756
// For slices of struct:
757757
Usage: unique=field
758758
759+
# ValidateFn
760+
761+
This validates that an object responds to a method that can return error or bool.
762+
By default it expects an interface `Validate() error` and check that the method
763+
does not return an error. Other methods can be specified using two signatures:
764+
If the method returns an error, it check if the return value is nil.
765+
If the method returns a boolean, it checks if the value is true.
766+
767+
// to use the default method Validate() error
768+
Usage: validateFn
769+
770+
// to use the custom method IsValid() bool (or error)
771+
Usage: validateFn=IsValid
772+
759773
# Alpha Only
760774
761775
This validates that a string value contains ASCII alpha characters only

translations/en/en.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1484,6 +1484,11 @@ func RegisterDefaultTranslations(v *validator.Validate, trans ut.Translator) (er
14841484
translation: "{0} must be a valid cve identifier",
14851485
override: false,
14861486
},
1487+
{
1488+
tag: "validateFn",
1489+
translation: "{0} must be a valid object",
1490+
override: false,
1491+
},
14871492
}
14881493

14891494
for _, t := range translations {

translations/en/en_test.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ import (
1010
"github.com/go-playground/validator/v10"
1111
)
1212

13+
type Foo struct{}
14+
15+
func (Foo) IsBar() bool { return false }
16+
1317
func TestTranslations(t *testing.T) {
1418
eng := english.New()
1519
uni := ut.New(eng, eng)
@@ -181,6 +185,7 @@ func TestTranslations(t *testing.T) {
181185
CveString string `validate:"cve"`
182186
MinDuration time.Duration `validate:"min=1h30m,max=2h"`
183187
MaxDuration time.Duration `validate:"min=1h30m,max=2h"`
188+
ValidateFn Foo `validate:"validateFn=IsBar"`
184189
}
185190

186191
var test Test
@@ -805,6 +810,10 @@ func TestTranslations(t *testing.T) {
805810
ns: "Test.MaxDuration",
806811
expected: "MaxDuration must be 2h or less",
807812
},
813+
{
814+
ns: "Test.ValidateFn",
815+
expected: "ValidateFn must be a valid object",
816+
},
808817
}
809818

810819
for _, tt := range tests {

translations/pt_BR/pt_BR.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1292,6 +1292,11 @@ func RegisterDefaultTranslations(v *validator.Validate, trans ut.Translator) (er
12921292
translation: "{0} deve ser um identificador cve válido",
12931293
override: false,
12941294
},
1295+
{
1296+
tag: "validateFn",
1297+
translation: "{0} deve ser um objeto válido",
1298+
override: false,
1299+
},
12951300
}
12961301

12971302
for _, t := range translations {

0 commit comments

Comments
 (0)