feat(validate): initial implementation

This is a bare-bones implementation of a basic validation package based
around a very simply Validatable interface:

    type Validatable interface {
        Validate() error
    }

The goal is to keep things as simple as possible, while also giving as
much control as possible over how validation logic is performed.
This commit is contained in:
2021-08-20 03:32:41 +01:00
parent 9e4b022762
commit 464467ec86
10 changed files with 2309 additions and 0 deletions

67
error.go Normal file
View File

@@ -0,0 +1,67 @@
package validate
import (
"errors"
"fmt"
"go.uber.org/multierr"
)
// Error represents validation errors, and implements Go's error type. Field
// indicates the struct field the validation error is relevant to, which is the
// full nested path relative to the top-level object being validated.
type Error struct {
Field string
Msg string
Err error
}
func (s *Error) Error() string {
msg := s.Msg
if msg == "" && s.Err != nil {
msg = s.Err.Error()
}
if msg == "" {
msg = "unknown error"
}
if s.Field == "" {
return msg
}
return fmt.Sprintf("%s: %s", s.Field, msg)
}
func (s *Error) Is(target error) bool {
return errors.Is(s.Err, target)
}
func (s *Error) Unwrap() error {
return s.Err
}
// Append combines two errors together into a single new error which internally
// keeps track of multiple errors via go.uber.org/multierr. If either error is a
// previously combined multierr, the returned error will be a flattened list of
// all errors.
func Append(errs error, err error) error {
return multierr.Append(errs, err)
}
// AppendError appends a new *Error type to errs with the Msg field populated
// with the provided msg.
func AppendError(errs error, msg string) error {
return multierr.Append(errs, &Error{Msg: msg})
}
// AppendFieldError appends a new *Error type to errs with Field and Msg
// populated with given field and msg values.
func AppendFieldError(errs error, field, msg string) error {
return multierr.Append(errs, &Error{Field: field, Msg: msg})
}
// Errors returns a slice of all errors appended into the given error.
func Errors(err error) []error {
return multierr.Errors(err)
}

456
error_test.go Normal file
View File

@@ -0,0 +1,456 @@
package validate
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
"go.uber.org/multierr"
)
func TestError_Error(t *testing.T) {
type fields struct {
Field string
Msg string
Err error
}
tests := []struct {
name string
fields fields
want string
}{
{
name: "empty",
fields: fields{},
want: "unknown error",
},
{
name: "field only",
fields: fields{
Field: "spec.images.0.name",
},
want: "spec.images.0.name: unknown error",
},
{
name: "msg only",
fields: fields{
Msg: "flux capacitor is missing",
},
want: "flux capacitor is missing",
},
{
name: "err only",
fields: fields{
Err: errors.New("flux capacitor is king"),
},
want: "flux capacitor is king",
},
{
name: "field and msg",
fields: fields{
Field: "spec.images.0.name",
Msg: "is required",
},
want: "spec.images.0.name: is required",
},
{
name: "field and err",
fields: fields{
Field: "spec",
Err: errors.New("something is wrong"),
},
want: "spec: something is wrong",
},
{
name: "msg and err",
fields: fields{
Msg: "flux capacitor is missing",
Err: errors.New("flux capacitor is king"),
},
want: "flux capacitor is missing",
},
{
name: "field, msg, and err",
fields: fields{
Field: "spec.images.0.name",
Msg: "is required",
Err: errors.New("something is wrong"),
},
want: "spec.images.0.name: is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := &Error{
Field: tt.fields.Field,
Msg: tt.fields.Msg,
Err: tt.fields.Err,
}
got := err.Error()
assert.Equal(t, tt.want, got)
})
}
}
func TestError_Is(t *testing.T) {
errTest1 := errors.New("errtest1")
errTest2 := errors.New("errtest2")
type fields struct {
Err error
}
tests := []struct {
name string
fields fields
target error
want bool
}{
{
name: "empty",
fields: fields{},
target: errTest1,
want: false,
},
{
name: "Err and target match",
fields: fields{Err: errTest1},
target: errTest1,
want: true,
},
{
name: "Err and target do not match",
fields: fields{Err: errTest2},
target: errTest1,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := &Error{Err: tt.fields.Err}
got := errors.Is(err, tt.target)
assert.Equal(t, tt.want, got)
})
}
}
func TestError_Unwrap(t *testing.T) {
errTest1 := errors.New("errtest1")
errTest2 := errors.New("errtest2")
type fields struct {
Err error
}
tests := []struct {
name string
fields fields
want error
}{
{
name: "empty",
fields: fields{},
want: nil,
},
{
name: "Err test1",
fields: fields{Err: errTest1},
want: errTest1,
},
{
name: "Err test2",
fields: fields{Err: errTest2},
want: errTest2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := &Error{Err: tt.fields.Err}
got := err.Unwrap()
assert.Equal(t, tt.want, got)
})
}
}
func TestAppend(t *testing.T) {
type args struct {
errs error
err error
}
tests := []struct {
name string
args args
want []error
}{
{
name: "append nil to nil",
args: args{
errs: nil,
err: nil,
},
want: []error{},
},
{
name: "append nil to err",
args: args{
errs: errors.New("foo"),
err: nil,
},
want: []error{
errors.New("foo"),
},
},
{
name: "append nil to multi err",
args: args{
errs: multierr.Combine(errors.New("foo"), errors.New("bar")),
err: nil,
},
want: []error{
errors.New("foo"),
errors.New("bar"),
},
},
{
name: "append err to nil",
args: args{
errs: nil,
err: errors.New("foo"),
},
want: []error{
errors.New("foo"),
},
},
{
name: "append err to err",
args: args{
errs: errors.New("foo"),
err: errors.New("bar"),
},
want: []error{
errors.New("foo"),
errors.New("bar"),
},
},
{
name: "append err to multi err",
args: args{
errs: multierr.Combine(errors.New("foo"), errors.New("bar")),
err: errors.New("baz"),
},
want: []error{
errors.New("foo"),
errors.New("bar"),
errors.New("baz"),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := Append(tt.args.errs, tt.args.err)
if len(tt.want) == 0 {
assert.Nil(t, err)
}
got := multierr.Errors(err)
assert.ElementsMatch(t, tt.want, got)
})
}
}
func TestAppendError(t *testing.T) {
type args struct {
errs error
msg string
}
tests := []struct {
name string
args args
want []error
}{
{
name: "append empty msg to nil",
args: args{
errs: nil,
msg: "",
},
want: []error{
&Error{},
},
},
{
name: "append msg to nil",
args: args{
errs: nil,
msg: "foo",
},
want: []error{
&Error{Msg: "foo"},
},
},
{
name: "append msg to err",
args: args{
errs: errors.New("foo"),
msg: "bar",
},
want: []error{
errors.New("foo"),
&Error{Msg: "bar"},
},
},
{
name: "append msg to multi err",
args: args{
errs: multierr.Combine(errors.New("foo"), errors.New("bar")),
msg: "baz",
},
want: []error{
errors.New("foo"),
errors.New("bar"),
&Error{Msg: "baz"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := AppendError(tt.args.errs, tt.args.msg)
if len(tt.want) == 0 {
assert.Nil(t, err)
}
got := multierr.Errors(err)
assert.ElementsMatch(t, tt.want, got)
})
}
}
func TestAppendFieldError(t *testing.T) {
type args struct {
errs error
field string
msg string
}
tests := []struct {
name string
args args
want []error
}{
{
name: "append empty field and msg to nil",
args: args{
errs: nil,
field: "",
msg: "",
},
want: []error{
&Error{},
},
},
{
name: "append msg to nil",
args: args{
errs: nil,
field: "Type",
msg: "foo",
},
want: []error{
&Error{Field: "Type", Msg: "foo"},
},
},
{
name: "append msg to err",
args: args{
errs: errors.New("foo"),
field: "Type",
msg: "bar",
},
want: []error{
errors.New("foo"),
&Error{Field: "Type", Msg: "bar"},
},
},
{
name: "append msg to multi err",
args: args{
errs: multierr.Combine(errors.New("foo"), errors.New("bar")),
field: "Type",
msg: "baz",
},
want: []error{
errors.New("foo"),
errors.New("bar"),
&Error{Field: "Type", Msg: "baz"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := AppendFieldError(tt.args.errs, tt.args.field, tt.args.msg)
if len(tt.want) == 0 {
assert.Nil(t, err)
}
got := multierr.Errors(err)
assert.ElementsMatch(t, tt.want, got)
})
}
}
func TestErrors(t *testing.T) {
type args struct {
err error
}
tests := []struct {
name string
args args
want []error
}{
{
name: "nil",
args: args{err: nil},
want: nil,
},
{
name: "single error",
args: args{err: errors.New("foo")},
want: []error{errors.New("foo")},
},
{
name: "multi error with one error",
args: args{
err: multierr.Combine(errors.New("foo")),
},
want: []error{
errors.New("foo"),
},
},
{
name: "multi error with two errors",
args: args{
err: multierr.Combine(errors.New("foo"), errors.New("bar")),
},
want: []error{
errors.New("foo"),
errors.New("bar"),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Errors(tt.args.err)
assert.Equal(t, tt.want, got)
})
}
}

9
go.mod Normal file
View File

@@ -0,0 +1,9 @@
module github.com/romdo/go-validate
go 1.15
require (
github.com/stretchr/testify v1.7.0
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.7.0
)

19
go.sum Normal file
View File

@@ -0,0 +1,19 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.7.0 h1:zaiO/rmgFjbmCXdSYJWQcdvOCsthmdaHfr3Gm2Kx4Ec=
go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

36
helpers.go Normal file
View File

@@ -0,0 +1,36 @@
package validate
import (
"reflect"
)
// RequireField returns a Error type for the given field if provided value is
// empty/zero.
func RequireField(field string, value interface{}) error {
err := &Error{Field: field, Msg: "is required"}
v := reflect.ValueOf(value)
if v.Kind() == reflect.Ptr {
if v.IsNil() {
return err
}
v = v.Elem()
}
if !v.IsValid() {
return err
}
switch v.Kind() { //nolint:exhaustive
case reflect.Map, reflect.Slice:
if v.Len() == 0 {
return err
}
default:
if v.IsZero() {
return err
}
}
return nil
}

406
helpers_test.go Normal file
View File

@@ -0,0 +1,406 @@
package validate
import (
"testing"
"github.com/stretchr/testify/assert"
)
func stringPtr(s string) *string {
return &s
}
func TestRequireField(t *testing.T) {
var nilMapString map[string]string
emptyMapString := map[string]string{}
mapString := map[string]string{"foo": "bar"}
type testStruct struct {
Name string
}
type args struct {
field string
value interface{}
}
tests := []struct {
name string
args args
want error
}{
{
name: "nil",
args: args{
field: "Title",
value: nil,
},
want: &Error{Field: "Title", Msg: "is required"},
},
{
name: "nil pointer",
args: args{
field: "Title",
value: &nilMapString,
},
want: &Error{Field: "Title", Msg: "is required"},
},
{
name: "true boolean",
args: args{
field: "Book",
value: true,
},
want: nil,
},
{
name: "false boolean",
args: args{
field: "Book",
value: false,
},
want: &Error{Field: "Book", Msg: "is required"},
},
{
name: "int",
args: args{
field: "Count",
value: int(834),
},
want: nil,
},
{
name: "zero int",
args: args{
field: "Count",
value: int(0),
},
want: &Error{Field: "Count", Msg: "is required"},
},
{
name: "int8",
args: args{
field: "Ticks",
value: int8(3),
},
want: nil,
},
{
name: "zero int8",
args: args{
field: "Ticks",
value: int8(0),
},
want: &Error{Field: "Ticks", Msg: "is required"},
},
{
name: "int16",
args: args{
field: "Ticks",
value: int16(3),
},
want: nil,
},
{
name: "zero int16",
args: args{
field: "Ticks",
value: int16(0),
},
want: &Error{Field: "Ticks", Msg: "is required"},
},
{
name: "int32",
args: args{
field: "Ticks",
value: int32(3),
},
want: nil,
},
{
name: "zero int32",
args: args{
field: "Ticks",
value: int32(0),
},
want: &Error{Field: "Ticks", Msg: "is required"},
},
{
name: "int64",
args: args{
field: "Ticks",
value: int64(3),
},
want: nil,
},
{
name: "zero int64",
args: args{
field: "Ticks",
value: int64(0),
},
want: &Error{Field: "Ticks", Msg: "is required"},
},
{
name: "zero uint",
args: args{
field: "Count",
value: uint(0),
},
want: &Error{Field: "Count", Msg: "is required"},
},
{
name: "uint8",
args: args{
field: "Ticks",
value: uint8(3),
},
want: nil,
},
{
name: "zero uint8",
args: args{
field: "Ticks",
value: uint8(0),
},
want: &Error{Field: "Ticks", Msg: "is required"},
},
{
name: "uint16",
args: args{
field: "Ticks",
value: uint16(3),
},
want: nil,
},
{
name: "zero uint16",
args: args{
field: "Ticks",
value: uint16(0),
},
want: &Error{Field: "Ticks", Msg: "is required"},
},
{
name: "uint32",
args: args{
field: "Ticks",
value: uint32(3),
},
want: nil,
},
{
name: "zero uint32",
args: args{
field: "Ticks",
value: uint32(0),
},
want: &Error{Field: "Ticks", Msg: "is required"},
},
{
name: "uint64",
args: args{
field: "Ticks",
value: uint64(3),
},
want: nil,
},
{
name: "zero uint64",
args: args{
field: "Ticks",
value: uint64(0),
},
want: &Error{Field: "Ticks", Msg: "is required"},
},
{
name: "complex64",
args: args{
field: "Offset",
value: complex64(3),
},
want: nil,
},
{
name: "zero complex64",
args: args{
field: "Offset",
value: complex64(0),
},
want: &Error{Field: "Offset", Msg: "is required"},
},
{
name: "complex128",
args: args{
field: "Offset",
value: complex128(3),
},
want: nil,
},
{
name: "zero complex128",
args: args{
field: "Offset",
value: complex128(0),
},
want: &Error{Field: "Offset", Msg: "is required"},
},
{
name: "array",
args: args{
field: "List",
value: [3]string{"foo", "bar", "baz"},
},
want: nil,
},
{
name: "empty array",
args: args{
field: "List",
value: [3]string{},
},
want: &Error{Field: "List", Msg: "is required"},
},
{
name: "chan",
args: args{
field: "Comms",
value: make(chan int),
},
want: nil,
},
{
name: "func",
args: args{
field: "Callback",
value: func() error { return nil },
},
want: nil,
},
{
name: "map",
args: args{
field: "Lookup",
value: map[string]string{"foo": "bar"},
},
want: nil,
},
{
name: "map pointer",
args: args{
field: "Lookup",
value: &mapString,
},
want: nil,
},
{
name: "empty map",
args: args{
field: "Lookup",
value: map[string]string{},
},
want: &Error{Field: "Lookup", Msg: "is required"},
},
{
name: "empty map pointer",
args: args{
field: "Lookup",
value: &emptyMapString,
},
want: &Error{Field: "Lookup", Msg: "is required"},
},
{
name: "nil map",
args: args{
field: "Lookup",
value: nilMapString,
},
want: &Error{Field: "Lookup", Msg: "is required"},
},
{
name: "slice",
args: args{
field: "List",
value: []string{"foo", "bar", "baz"},
},
want: nil,
},
{
name: "empty slice",
args: args{
field: "List",
value: []string{},
},
want: &Error{Field: "List", Msg: "is required"},
},
{
name: "string",
args: args{
field: "Book",
value: "foo",
},
want: nil,
},
{
name: "string pointer",
args: args{
field: "Book",
value: stringPtr("foo"),
},
want: nil,
},
{
name: "empty string",
args: args{
field: "Book",
value: "",
},
want: &Error{Field: "Book", Msg: "is required"},
},
{
name: "empty string pointer",
args: args{
field: "Book",
value: stringPtr(""),
},
want: &Error{Field: "Book", Msg: "is required"},
},
{
name: "struct",
args: args{
field: "Thing",
value: testStruct{Name: "hi"},
},
want: nil,
},
{
name: "struct pointer",
args: args{
field: "Thing",
value: &testStruct{Name: "hi"},
},
want: nil,
},
{
name: "empty struct",
args: args{
field: "Thing",
value: testStruct{},
},
want: &Error{Field: "Thing", Msg: "is required"},
},
{
name: "empty struct pointer",
args: args{
field: "Thing",
value: &testStruct{},
},
want: &Error{Field: "Thing", Msg: "is required"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := RequireField(tt.args.field, tt.args.value)
assert.Equal(t, tt.want, got)
})
}
}

162
validate.go Normal file
View File

@@ -0,0 +1,162 @@
// Package validate is yet another Go struct/object validation package, with a
// focus on simplicity, flexibility, and full control over validation logic.
//
// Interface
//
// To add validation to any type, simply implement the Validatable interface:
//
// type Validatable interface {
// Validate() error
// }
//
// To mark a object as failing validation, the Validate method simply needs to
// return a error.
//
// When validating array, slice, map, and struct types each item and/or field
// that implements Validatable will be validated, meaning deeply nested structs
// can be fully validated, and the nested path to each object is tracked and
// reported back any validation errors.
//
// Multiple Errors
//
// Multiple errors can be reported from the Validate method using one of the
// available Append helper functions which append errors together. Under the
// hood the go.uber.org/multierr package is used to represent multiple errors as
// a single error return type, and you can in fact just directly use multierr in
// the a type's Validate method.
//
// Structs and Field-specific Errors
//
// When validating a struct, you are likely to have multiple errors for multiple
// fields. To specify which field on the struct the error relates to, you have
// to return a *validate.Error instead of a normal Go error type. For example:
//
// type Book struct {
// Title string
// Author string
// }
//
// func (s *Book) Validate() error {
// var errs error
//
// if s.Title == "" {
// errs = validate.Append(errs, &validate.Error{
// Field: "Title", Msg: "is required",
// })
// }
//
// if s.Author == "" {
// // Yields the same result as the Title field check above.
// errs = validate.AppendFieldError(errs, "Author", "is required")
// }
//
// return errs
// }
//
// With the above example, if you validate a empty *Book:
//
// err := validate.Validate(&Book{})
// for _, e := range validate.Errors(err) {
// fmt.Println(e.Error())
// }
//
// The following errors would be printed:
//
// Title: is required
// Kind: is required
//
// Error type
//
// All errors will be wrapped in a *Error before being returned, which is used
// to keep track of the path and field the error relates to. There are various
// helpers available to create Error instances.
//
// Handling Validation Errors
//
// As mentioned above, multiple errors are wrapped up into a single error return
// value using go.uber.org/multierr. You can access all errors individually with
// Errors(), which accepts a single error, and returns []error. The Errors()
// function is just wrapper around multierr.Errors(), so you could use that
// instead if you prefer.
//
// Struct Field Tags
//
// Fields on a struct which customize the name via a json, yaml, or form field
// tag, will automatically have the field name converted to the name in the tag
// in returned *Error types with a non-empty Field value.
//
// You can customize the field name conversion logic by creating a custom
// Validator instance, and calling FieldNameFunc() on it.
//
// Nested Validatable Objects
//
// All items/fields on any structs, maps, slices or arrays which are encountered
// will be validated if they implement the Validatable interface. While
// traversing nested data structures, a path list tracks the location of the
// current object being validation in relation to the top-level object being
// validated. This path is used within the field in the final output errors.
//
// By default path components are joined with a dot, but this can be customized
// when using a custom Validator instance and calling FieldJoinFunc() passing in
// a custom function to handle path joining.
//
// As an example, if our Book struct from above is nested within the following
// structs:
//
// type Order struct {
// Items []*Item `json:"items"`
// }
//
// type Item struct {
// Book *Book `json:"book"`
// }
//
// And we have a Order where the book in the second Item has a empty Author
// field:
//
// err := validate.Validate(&Order{
// Items: []*Item{
// {Book: &Book{Title: "The Traveler", Author: "John Twelve Hawks"}},
// {Book: &Book{Title: "The Firm"}},
// },
// })
// for _, e := range validate.Errors(err) {
// fmt.Println(e.Error())
// }
//
// Then we would get the following error:
//
// items.1.book.Author: is required
//
// Note how both "items" and "book" are lower cased thanks to the json tags on
// the struct fields, while our Book struct does not have a json tag for the
// Author field.
//
// Also note that the error message does not start with "Order". The field path
// is relative to the object being validated, hence the top-level object is not
// part of the returned field path.
package validate
// global is a private instance of Validator to enable the package root-level
// Validate() function.
var global = New()
// Validate will validate the given object. Structs, maps, slices, and arrays
// will have each of their fields/items validated, effectively performing a
// deep-validation.
func Validate(v interface{}) error {
return global.Validate(v)
}
// Validatable is the primary interface that a object needs to implement to be
// validatable with Validator.
//
// Validation errors are reported by returning a error from the Validate
// method. Multiple errors can be combined into a single error to return with
// Append() and related functions, or via go.uber.org/multierr.
//
// For validatable structs, the field the validation error relates to can be
// specified by returning a *Error type with the Field value specified.
type Validatable interface {
Validate() error
}

907
validate_test.go Normal file
View File

@@ -0,0 +1,907 @@
package validate_test
import (
"errors"
"strings"
"testing"
"github.com/romdo/go-validate"
"github.com/stretchr/testify/assert"
)
//
// Test helper types
//
type validatableString string
func (s validatableString) Validate() error {
if strings.Contains(string(s), " ") {
return &validate.Error{Msg: "must not contain space"}
}
return nil
}
type validatableStruct struct {
Foo string
Bar string `json:"bar"`
Foz string `yaml:"foz"`
Baz string `form:"baz"`
f func() error
}
func (s *validatableStruct) Validate() error {
if s.f == nil {
return nil
}
return s.f()
}
type nestedStruct struct {
OtherField *validatableStruct
OtherFieldJSON *validatableStruct `json:"other_field,omitempty"`
OtherFieldYAML *validatableStruct `yaml:"otherField,omitempty"`
OtherFieldFORM *validatableStruct `form:"other-field,omitempty"`
skippedField *validatableStruct
SkippedFieldJSON *validatableStruct `json:"-,omitempty"`
SkippedFieldYAML *validatableStruct `yaml:"-,omitempty"`
SkippedFieldFORM *validatableStruct `form:"-,omitempty"`
OtherArray [5]*validatableStruct
OtherArrayJSON [5]*validatableStruct `json:"other_array,omitempty"`
OtherArrayYAML [5]*validatableStruct `yaml:"otherArray,omitempty"`
OtherArrayFORM [5]*validatableStruct `form:"other-array,omitempty"`
OtherSlice []*validatableStruct
OtherSliceJSON []*validatableStruct `json:"other_slice,omitempty"`
OtherSliceYAML []*validatableStruct `yaml:"otherSlice,omitempty"`
OtherSliceFORM []*validatableStruct `form:"other-slice,omitempty"`
OtherStringMap map[string]*validatableStruct
OtherStringMapJSON map[string]*validatableStruct `json:"other_string_map,omitempty"`
OtherStringMapYAML map[string]*validatableStruct `yaml:"otherStringMap,omitempty"`
OtherStringMapFORM map[string]*validatableStruct `form:"other-string-map,omitempty"`
OtherIntMap map[int]*validatableStruct
OtherIntMapJSON map[int]*validatableStruct `json:"other_int_map,omitempty"`
OtherIntMapYAML map[int]*validatableStruct `yaml:"otherIntMap,omitempty"`
OtherIntMapFORM map[int]*validatableStruct `form:"other-int-map,omitempty"`
OtherStruct *nestedStruct
OtherStructJSON *nestedStruct `json:"other_struct,omitempty"`
OtherStructYAML *nestedStruct `yaml:"otherStruct,omitempty"`
OtherStructFORM *nestedStruct `form:"other-struct,omitempty"`
}
//
// Tests
//
func TestValidate(t *testing.T) {
mixedValidationErrors := &validatableStruct{
f: func() error {
var errs error
errs = validate.Append(errs, &validate.Error{
Field: "Foo", Msg: "is required",
Err: errors.New("oops"),
})
errs = validate.Append(errs, errors.New("bar: is missing"))
return errs
},
}
tests := []struct {
name string
obj interface{}
wantErrs []error
}{
{
name: "nil",
obj: nil,
wantErrs: []error{},
},
{
name: "no error",
obj: &validatableStruct{},
wantErrs: nil,
},
{
name: "valid validatable string type",
obj: validatableString("hello-world"),
wantErrs: []error{},
},
{
name: "invalid validatable string type",
obj: validatableString("hello world"),
wantErrs: []error{
&validate.Error{Msg: "must not contain space"},
},
},
{
name: "single Go error",
obj: &validatableStruct{f: func() error {
return errors.New("foo: is required")
}},
wantErrs: []error{
&validate.Error{Err: errors.New("foo: is required")},
},
},
{
name: "single *validate.Error",
obj: &validatableStruct{f: func() error {
return &validate.Error{
Field: "foo", Msg: "is required", Err: errors.New("oops"),
}
}},
wantErrs: []error{
&validate.Error{
Field: "foo", Msg: "is required", Err: errors.New("oops"),
},
},
},
{
name: "multiple Go errors",
obj: &validatableStruct{f: func() error {
var errs error
errs = validate.Append(errs, errors.New("foo: is required"))
errs = validate.Append(errs, errors.New("bar: is missing"))
return errs
}},
wantErrs: []error{
&validate.Error{Err: errors.New("foo: is required")},
&validate.Error{Err: errors.New("bar: is missing")},
},
},
{
name: "multiple *validate.Error",
obj: &validatableStruct{f: func() error {
var errs error
errs = validate.Append(errs, &validate.Error{
Field: "foo", Msg: "is required", Err: errors.New("oops"),
})
errs = validate.Append(errs, &validate.Error{
Field: "bar", Msg: "is required", Err: errors.New("whoops"),
})
return errs
}},
wantErrs: []error{
&validate.Error{
Field: "foo", Msg: "is required", Err: errors.New("oops"),
},
&validate.Error{
Field: "bar", Msg: "is required", Err: errors.New("whoops"),
},
},
},
{
name: "mix of Go error and *validate.Error",
obj: mixedValidationErrors,
wantErrs: []error{
&validate.Error{
Field: "Foo", Msg: "is required", Err: errors.New("oops"),
},
&validate.Error{Err: errors.New("bar: is missing")},
},
},
//
// Field name conversion
//
{
name: "no json, yaml or form field tag",
obj: &validatableStruct{f: func() error {
return &validate.Error{Field: "Foo", Msg: "is required"}
}},
wantErrs: []error{
&validate.Error{Field: "Foo", Msg: "is required"},
},
},
{
name: "converts field name via json field tag",
obj: &validatableStruct{f: func() error {
return &validate.Error{Field: "Bar", Msg: "is required"}
}},
wantErrs: []error{
&validate.Error{Field: "bar", Msg: "is required"},
},
},
{
name: "converts field name via yaml field tag",
obj: &validatableStruct{f: func() error {
return &validate.Error{Field: "Foz", Msg: "is required"}
}},
wantErrs: []error{
&validate.Error{Field: "foz", Msg: "is required"},
},
},
{
name: "converts field name via form field tag",
obj: &validatableStruct{f: func() error {
return &validate.Error{Field: "Baz", Msg: "is required"}
}},
wantErrs: []error{
&validate.Error{Field: "baz", Msg: "is required"},
},
},
{
name: "nested with no validation errors",
obj: &nestedStruct{
OtherField: &validatableStruct{},
OtherArray: [5]*validatableStruct{{}, {}, {}, {}},
OtherSlice: []*validatableStruct{{}},
OtherStringMap: map[string]*validatableStruct{
"hi": {},
"bye": {},
},
OtherIntMap: map[int]*validatableStruct{42: {}, 64: {}},
OtherStruct: &nestedStruct{
OtherField: &validatableStruct{},
},
},
wantErrs: []error{},
},
//
// Nested in a struct field.
//
{
name: "nested in a struct field",
obj: &nestedStruct{
OtherField: mixedValidationErrors,
},
wantErrs: []error{
&validate.Error{
Field: "OtherField.Foo", Msg: "is required",
Err: errors.New("oops"),
},
&validate.Error{
Field: "OtherField", Err: errors.New("bar: is missing"),
},
},
},
{
name: "nested in a struct field with json tag",
obj: &nestedStruct{
OtherFieldJSON: mixedValidationErrors,
},
wantErrs: []error{
&validate.Error{
Field: "other_field.Foo", Msg: "is required",
Err: errors.New("oops"),
},
&validate.Error{
Field: "other_field", Err: errors.New("bar: is missing"),
},
},
},
{
name: "nested in a struct field with yaml tag",
obj: &nestedStruct{
OtherFieldYAML: mixedValidationErrors,
},
wantErrs: []error{
&validate.Error{
Field: "otherField.Foo", Msg: "is required",
Err: errors.New("oops"),
},
&validate.Error{
Field: "otherField", Err: errors.New("bar: is missing"),
},
},
},
{
name: "nested in a struct field with form tag",
obj: &nestedStruct{
OtherFieldFORM: mixedValidationErrors,
},
wantErrs: []error{
&validate.Error{
Field: "other-field.Foo", Msg: "is required",
Err: errors.New("oops"),
},
&validate.Error{
Field: "other-field", Err: errors.New("bar: is missing"),
},
},
},
//
// Nested in a unexposed/ignored fields.
//
{
name: "nested in a unexposed field",
obj: &nestedStruct{
skippedField: mixedValidationErrors,
},
wantErrs: []error{},
},
{
name: "nested in a struct field skipped by json tag",
obj: &nestedStruct{
SkippedFieldJSON: mixedValidationErrors,
},
wantErrs: []error{},
},
{
name: "nested in a struct field skipped by yaml tag",
obj: &nestedStruct{
SkippedFieldYAML: mixedValidationErrors,
},
wantErrs: []error{},
},
{
name: "nested in a struct field skipped by yaml tag",
obj: &nestedStruct{
SkippedFieldFORM: mixedValidationErrors,
},
wantErrs: []error{},
},
//
// Nested in an array.
//
{
name: "nested in an array",
obj: &nestedStruct{
OtherArray: [5]*validatableStruct{
mixedValidationErrors,
mixedValidationErrors,
},
},
wantErrs: []error{
&validate.Error{
Field: "OtherArray.0.Foo", Msg: "is required",
Err: errors.New("oops"),
},
&validate.Error{
Field: "OtherArray.0", Err: errors.New("bar: is missing"),
},
&validate.Error{
Field: "OtherArray.1.Foo", Msg: "is required",
Err: errors.New("oops"),
},
&validate.Error{
Field: "OtherArray.1", Err: errors.New("bar: is missing"),
},
},
},
{
name: "nested in an array with json tag",
obj: &nestedStruct{
OtherArrayJSON: [5]*validatableStruct{
mixedValidationErrors,
mixedValidationErrors,
},
},
wantErrs: []error{
&validate.Error{
Field: "other_array.0.Foo", Msg: "is required",
Err: errors.New("oops"),
},
&validate.Error{
Field: "other_array.0", Err: errors.New("bar: is missing"),
},
&validate.Error{
Field: "other_array.1.Foo", Msg: "is required",
Err: errors.New("oops"),
},
&validate.Error{
Field: "other_array.1", Err: errors.New("bar: is missing"),
},
},
},
{
name: "nested in an array with yaml tag",
obj: &nestedStruct{
OtherArrayYAML: [5]*validatableStruct{
mixedValidationErrors,
mixedValidationErrors,
},
},
wantErrs: []error{
&validate.Error{
Field: "otherArray.0.Foo", Msg: "is required",
Err: errors.New("oops"),
},
&validate.Error{
Field: "otherArray.0", Err: errors.New("bar: is missing"),
},
&validate.Error{
Field: "otherArray.1.Foo", Msg: "is required",
Err: errors.New("oops"),
},
&validate.Error{
Field: "otherArray.1", Err: errors.New("bar: is missing"),
},
},
},
{
name: "nested in an array with form tag",
obj: &nestedStruct{
OtherArrayFORM: [5]*validatableStruct{
mixedValidationErrors,
mixedValidationErrors,
},
},
wantErrs: []error{
&validate.Error{
Field: "other-array.0.Foo", Msg: "is required",
Err: errors.New("oops"),
},
&validate.Error{
Field: "other-array.0", Err: errors.New("bar: is missing"),
},
&validate.Error{
Field: "other-array.1.Foo", Msg: "is required",
Err: errors.New("oops"),
},
&validate.Error{
Field: "other-array.1", Err: errors.New("bar: is missing"),
},
},
},
//
// Nested in a slice.
//
{
name: "nested in a slice",
obj: &nestedStruct{
OtherSlice: []*validatableStruct{
mixedValidationErrors,
mixedValidationErrors,
},
},
wantErrs: []error{
&validate.Error{
Field: "OtherSlice.0.Foo", Msg: "is required",
Err: errors.New("oops"),
},
&validate.Error{
Field: "OtherSlice.0", Err: errors.New("bar: is missing"),
},
&validate.Error{
Field: "OtherSlice.1.Foo", Msg: "is required",
Err: errors.New("oops"),
},
&validate.Error{
Field: "OtherSlice.1", Err: errors.New("bar: is missing"),
},
},
},
{
name: "nested in a slice with json tag",
obj: &nestedStruct{
OtherSliceJSON: []*validatableStruct{
mixedValidationErrors,
mixedValidationErrors,
},
},
wantErrs: []error{
&validate.Error{
Field: "other_slice.0.Foo", Msg: "is required",
Err: errors.New("oops"),
},
&validate.Error{
Field: "other_slice.0", Err: errors.New("bar: is missing"),
},
&validate.Error{
Field: "other_slice.1.Foo", Msg: "is required",
Err: errors.New("oops"),
},
&validate.Error{
Field: "other_slice.1", Err: errors.New("bar: is missing"),
},
},
},
{
name: "nested in a slice with yaml tag",
obj: &nestedStruct{
OtherSliceYAML: []*validatableStruct{
mixedValidationErrors,
mixedValidationErrors,
},
},
wantErrs: []error{
&validate.Error{
Field: "otherSlice.0.Foo", Msg: "is required",
Err: errors.New("oops"),
},
&validate.Error{
Field: "otherSlice.0", Err: errors.New("bar: is missing"),
},
&validate.Error{
Field: "otherSlice.1.Foo", Msg: "is required",
Err: errors.New("oops"),
},
&validate.Error{
Field: "otherSlice.1", Err: errors.New("bar: is missing"),
},
},
},
{
name: "nested in a slice with form tag",
obj: &nestedStruct{
OtherSliceFORM: []*validatableStruct{
mixedValidationErrors,
mixedValidationErrors,
},
},
wantErrs: []error{
&validate.Error{
Field: "other-slice.0.Foo", Msg: "is required",
Err: errors.New("oops"),
},
&validate.Error{
Field: "other-slice.0", Err: errors.New("bar: is missing"),
},
&validate.Error{
Field: "other-slice.1.Foo", Msg: "is required",
Err: errors.New("oops"),
},
&validate.Error{
Field: "other-slice.1", Err: errors.New("bar: is missing"),
},
},
},
//
// Nested in an string map.
//
{
name: "nested in a string map",
obj: &nestedStruct{
OtherStringMap: map[string]*validatableStruct{
"hello": mixedValidationErrors,
"world": mixedValidationErrors,
},
},
wantErrs: []error{
&validate.Error{
Field: "OtherStringMap.hello.Foo", Msg: "is required",
Err: errors.New("oops"),
},
&validate.Error{
Field: "OtherStringMap.hello",
Err: errors.New("bar: is missing"),
},
&validate.Error{
Field: "OtherStringMap.world.Foo", Msg: "is required",
Err: errors.New("oops"),
},
&validate.Error{
Field: "OtherStringMap.world",
Err: errors.New("bar: is missing"),
},
},
},
{
name: "nested in a string map with json tag",
obj: &nestedStruct{
OtherStringMapJSON: map[string]*validatableStruct{
"hello": mixedValidationErrors,
"world": mixedValidationErrors,
},
},
wantErrs: []error{
&validate.Error{
Field: "other_string_map.hello.Foo", Msg: "is required",
Err: errors.New("oops"),
},
&validate.Error{
Field: "other_string_map.hello",
Err: errors.New("bar: is missing"),
},
&validate.Error{
Field: "other_string_map.world.Foo", Msg: "is required",
Err: errors.New("oops"),
},
&validate.Error{
Field: "other_string_map.world",
Err: errors.New("bar: is missing"),
},
},
},
{
name: "nested in a string map with yaml tag",
obj: &nestedStruct{
OtherStringMapYAML: map[string]*validatableStruct{
"hello": mixedValidationErrors,
"world": mixedValidationErrors,
},
},
wantErrs: []error{
&validate.Error{
Field: "otherStringMap.hello.Foo", Msg: "is required",
Err: errors.New("oops"),
},
&validate.Error{
Field: "otherStringMap.hello",
Err: errors.New("bar: is missing"),
},
&validate.Error{
Field: "otherStringMap.world.Foo", Msg: "is required",
Err: errors.New("oops"),
},
&validate.Error{
Field: "otherStringMap.world",
Err: errors.New("bar: is missing"),
},
},
},
{
name: "nested in a string map with form tag",
obj: &nestedStruct{
OtherStringMapFORM: map[string]*validatableStruct{
"hello": mixedValidationErrors,
"world": mixedValidationErrors,
},
},
wantErrs: []error{
&validate.Error{
Field: "other-string-map.hello.Foo", Msg: "is required",
Err: errors.New("oops"),
},
&validate.Error{
Field: "other-string-map.hello",
Err: errors.New("bar: is missing"),
},
&validate.Error{
Field: "other-string-map.world.Foo", Msg: "is required",
Err: errors.New("oops"),
},
&validate.Error{
Field: "other-string-map.world",
Err: errors.New("bar: is missing"),
},
},
},
//
// Nested in an int map.
//
{
name: "nested in a int map",
obj: &nestedStruct{
OtherIntMap: map[int]*validatableStruct{
42: mixedValidationErrors,
64: mixedValidationErrors,
},
},
wantErrs: []error{
&validate.Error{
Field: "OtherIntMap.42.Foo", Msg: "is required",
Err: errors.New("oops"),
},
&validate.Error{
Field: "OtherIntMap.42",
Err: errors.New("bar: is missing"),
},
&validate.Error{
Field: "OtherIntMap.64.Foo", Msg: "is required",
Err: errors.New("oops"),
},
&validate.Error{
Field: "OtherIntMap.64",
Err: errors.New("bar: is missing"),
},
},
},
{
name: "nested in a int map with json tag",
obj: &nestedStruct{
OtherIntMapJSON: map[int]*validatableStruct{
42: mixedValidationErrors,
64: mixedValidationErrors,
},
},
wantErrs: []error{
&validate.Error{
Field: "other_int_map.42.Foo", Msg: "is required",
Err: errors.New("oops"),
},
&validate.Error{
Field: "other_int_map.42",
Err: errors.New("bar: is missing"),
},
&validate.Error{
Field: "other_int_map.64.Foo", Msg: "is required",
Err: errors.New("oops"),
},
&validate.Error{
Field: "other_int_map.64",
Err: errors.New("bar: is missing"),
},
},
},
{
name: "nested in a int map with yaml tag",
obj: &nestedStruct{
OtherIntMapYAML: map[int]*validatableStruct{
42: mixedValidationErrors,
64: mixedValidationErrors,
},
},
wantErrs: []error{
&validate.Error{
Field: "otherIntMap.42.Foo", Msg: "is required",
Err: errors.New("oops"),
},
&validate.Error{
Field: "otherIntMap.42",
Err: errors.New("bar: is missing"),
},
&validate.Error{
Field: "otherIntMap.64.Foo", Msg: "is required",
Err: errors.New("oops"),
},
&validate.Error{
Field: "otherIntMap.64",
Err: errors.New("bar: is missing"),
},
},
},
{
name: "nested in a int map with form tag",
obj: &nestedStruct{
OtherIntMapFORM: map[int]*validatableStruct{
42: mixedValidationErrors,
64: mixedValidationErrors,
},
},
wantErrs: []error{
&validate.Error{
Field: "other-int-map.42.Foo", Msg: "is required",
Err: errors.New("oops"),
},
&validate.Error{
Field: "other-int-map.42",
Err: errors.New("bar: is missing"),
},
&validate.Error{
Field: "other-int-map.64.Foo", Msg: "is required",
Err: errors.New("oops"),
},
&validate.Error{
Field: "other-int-map.64",
Err: errors.New("bar: is missing"),
},
},
},
//
// Nested in another struct.
//
{
name: "nested in another struct",
obj: &nestedStruct{
OtherStruct: &nestedStruct{
OtherField: mixedValidationErrors,
OtherStringMap: map[string]*validatableStruct{
"world": mixedValidationErrors,
},
},
},
wantErrs: []error{
&validate.Error{
Field: "OtherStruct.OtherField.Foo", Msg: "is required",
Err: errors.New("oops"),
},
&validate.Error{
Field: "OtherStruct.OtherField",
Err: errors.New("bar: is missing"),
},
&validate.Error{
Field: "OtherStruct.OtherStringMap.world.Foo",
Msg: "is required",
Err: errors.New("oops"),
},
&validate.Error{
Field: "OtherStruct.OtherStringMap.world",
Err: errors.New("bar: is missing"),
},
},
},
{
name: "nested in a int map with json tag",
obj: &nestedStruct{
OtherStructJSON: &nestedStruct{
OtherFieldJSON: mixedValidationErrors,
OtherStringMapJSON: map[string]*validatableStruct{
"world": mixedValidationErrors,
},
},
},
wantErrs: []error{
&validate.Error{
Field: "other_struct.other_field.Foo", Msg: "is required",
Err: errors.New("oops"),
},
&validate.Error{
Field: "other_struct.other_field",
Err: errors.New("bar: is missing"),
},
&validate.Error{
Field: "other_struct.other_string_map.world.Foo",
Msg: "is required",
Err: errors.New("oops"),
},
&validate.Error{
Field: "other_struct.other_string_map.world",
Err: errors.New("bar: is missing"),
},
},
},
{
name: "nested in a int map with yaml tag",
obj: &nestedStruct{
OtherStructYAML: &nestedStruct{
OtherFieldYAML: mixedValidationErrors,
OtherStringMapYAML: map[string]*validatableStruct{
"world": mixedValidationErrors,
},
},
},
wantErrs: []error{
&validate.Error{
Field: "otherStruct.otherField.Foo", Msg: "is required",
Err: errors.New("oops"),
},
&validate.Error{
Field: "otherStruct.otherField",
Err: errors.New("bar: is missing"),
},
&validate.Error{
Field: "otherStruct.otherStringMap.world.Foo",
Msg: "is required",
Err: errors.New("oops"),
},
&validate.Error{
Field: "otherStruct.otherStringMap.world",
Err: errors.New("bar: is missing"),
},
},
},
{
name: "nested in a int map with form tag",
obj: &nestedStruct{
OtherStructFORM: &nestedStruct{
OtherFieldFORM: mixedValidationErrors,
OtherStringMapFORM: map[string]*validatableStruct{
"world": mixedValidationErrors,
},
},
},
wantErrs: []error{
&validate.Error{
Field: "other-struct.other-field.Foo", Msg: "is required",
Err: errors.New("oops"),
},
&validate.Error{
Field: "other-struct.other-field",
Err: errors.New("bar: is missing"),
},
&validate.Error{
Field: "other-struct.other-string-map.world.Foo",
Msg: "is required",
Err: errors.New("oops"),
},
&validate.Error{
Field: "other-struct.other-string-map.world",
Err: errors.New("bar: is missing"),
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validate.Validate(tt.obj)
if len(tt.wantErrs) == 0 {
assert.Nil(t, err, "validation error should be nil")
}
got := validate.Errors(err)
assert.ElementsMatch(t, tt.wantErrs, got)
})
}
}

161
validator.go Normal file
View File

@@ -0,0 +1,161 @@
package validate
import (
"errors"
"fmt"
"reflect"
"strconv"
"strings"
"go.uber.org/multierr"
)
// FieldNameFunc is a function which converts a given reflect.StructField to a
// string. The default will lookup json, yaml, and form field tags.
type FieldNameFunc func(reflect.StructField) string
// FieldJoinFunc joins a path slice with a given field. Both path and field may
// be empty values.
type FieldJoinFunc func(path []string, field string) string
// Validator validates Validatable objects.
type Validator struct {
fieldName FieldNameFunc
fieldJoin FieldJoinFunc
}
// New creates a new Validator.
func New() *Validator {
return &Validator{}
}
// Validate will validate the given object. Structs, maps, slices, and arrays
// will have each of their fields/items validated, effectively performing a
// deep-validation.
func (s *Validator) Validate(data interface{}) error {
if s.fieldName == nil {
s.fieldName = DefaultFieldName
}
if s.fieldJoin == nil {
s.fieldJoin = DefaultFieldJoin
}
return s.validate(nil, data)
}
// FieldNameFunc allows setting a custom FieldNameFunc method. It receives a
// reflect.StructField, and must return a string for the name of that field. If
// the returned string is empty, validation will not run against the field's
// value, or any nested data within.
func (s *Validator) FieldNameFunc(f FieldNameFunc) {
s.fieldName = f
}
// FieldJoinFunc allows setting a custom FieldJoinFunc method. It receives a
// string slice of parent fields, and a string of the field name the error is
// reported against. All parent paths, must be joined with the current.
func (s *Validator) FieldJoinFunc(f FieldJoinFunc) {
s.fieldJoin = f
}
func (s *Validator) validate(path []string, data interface{}) error {
var errs error
if data == nil {
return nil
}
d := reflect.ValueOf(data)
if d.Kind() == reflect.Ptr {
if d.IsNil() {
return nil
}
d = d.Elem()
}
if v, ok := data.(Validatable); ok {
verrs := v.Validate()
for _, err := range multierr.Errors(verrs) {
// Create a new Error for all errors returned by Validate function
// to correctly resolve field name, and also field path in relation
// to parent objects being validated.
newErr := &Error{}
e := &Error{}
if ok := errors.As(err, &e); ok {
field := e.Field
if field != "" {
if sf, ok := d.Type().FieldByName(e.Field); ok {
field = s.fieldName(sf)
}
}
newErr.Field = s.fieldJoin(path, field)
newErr.Msg = e.Msg
newErr.Err = e.Err
} else {
newErr.Field = s.fieldJoin(path, "")
newErr.Err = err
}
errs = multierr.Append(errs, newErr)
}
}
switch d.Kind() { //nolint:exhaustive
case reflect.Slice, reflect.Array:
for i := 0; i < d.Len(); i++ {
v := d.Index(i)
err := s.validate(append(path, strconv.Itoa(i)), v.Interface())
errs = multierr.Append(errs, err)
}
case reflect.Map:
for _, k := range d.MapKeys() {
v := d.MapIndex(k)
err := s.validate(append(path, fmt.Sprintf("%v", k)), v.Interface())
errs = multierr.Append(errs, err)
}
case reflect.Struct:
for i := 0; i < d.NumField(); i++ {
v := d.Field(i)
fldName := s.fieldName(d.Type().Field(i))
if v.CanSet() && fldName != "" {
err := s.validate(append(path, fldName), v.Interface())
errs = multierr.Append(errs, err)
}
}
}
return errs
}
// DefaultFieldName is the default FieldNameFunc used by Validator.
//
// Uses json, yaml, and form field tags to lookup field name first.
func DefaultFieldName(fld reflect.StructField) string {
name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0]
if name == "" {
name = strings.SplitN(fld.Tag.Get("yaml"), ",", 2)[0]
}
if name == "" {
name = strings.SplitN(fld.Tag.Get("form"), ",", 2)[0]
}
if name == "-" {
return ""
}
if name == "" {
return fld.Name
}
return name
}
// DefaultFieldJoin is the default FieldJoinFunc used by Validator.
func DefaultFieldJoin(path []string, field string) string {
if field != "" {
path = append(path, field)
}
return strings.Join(path, ".")
}

86
validator_test.go Normal file
View File

@@ -0,0 +1,86 @@
package validate
import (
"reflect"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
//
// Test helper types
//
type testStruct struct {
Foo string `json:"foo"`
f func() error
}
func (s *testStruct) Validate() error {
if s.f == nil {
return nil
}
return s.f()
}
type testNestedStruct struct {
OtherField *testStruct `yaml:"other_field"`
}
type MyStruct struct {
Name string
Kind string
}
//
// Tests
//
func TestNew(t *testing.T) {
got := New()
assert.IsType(t, &Validator{}, got)
}
func TestValidator_FieldNameFunc(t *testing.T) {
v := New()
v.FieldNameFunc(func(sf reflect.StructField) string {
return "<" + strings.ToUpper(sf.Name) + ">"
})
err := v.Validate(&testNestedStruct{
OtherField: &testStruct{f: func() error {
return &Error{Field: "Foo", Msg: "oops"}
}},
})
got := Errors(err)
assert.ElementsMatch(t, []error{
&Error{Field: "<OTHERFIELD>.<FOO>", Msg: "oops"},
}, got)
}
func TestValidator_FieldJoinFunc(t *testing.T) {
v := New()
v.FieldJoinFunc(func(path []string, field string) string {
if field != "" {
path = append(path, field)
}
return "[" + strings.Join(path, "][") + "]"
})
err := v.Validate(&testNestedStruct{
OtherField: &testStruct{f: func() error {
return &Error{Field: "Foo", Msg: "oops"}
}},
})
got := Errors(err)
assert.ElementsMatch(t, []error{
&Error{Field: "[other_field][foo]", Msg: "oops"},
}, got)
}