From 464467ec86de60aefe6d2225671203872b9ad276 Mon Sep 17 00:00:00 2001 From: Jim Myhrberg Date: Fri, 20 Aug 2021 03:32:41 +0100 Subject: [PATCH] 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. --- error.go | 67 ++++ error_test.go | 456 +++++++++++++++++++++++ go.mod | 9 + go.sum | 19 + helpers.go | 36 ++ helpers_test.go | 406 +++++++++++++++++++++ validate.go | 162 +++++++++ validate_test.go | 907 ++++++++++++++++++++++++++++++++++++++++++++++ validator.go | 161 ++++++++ validator_test.go | 86 +++++ 10 files changed, 2309 insertions(+) create mode 100644 error.go create mode 100644 error_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 helpers.go create mode 100644 helpers_test.go create mode 100644 validate.go create mode 100644 validate_test.go create mode 100644 validator.go create mode 100644 validator_test.go diff --git a/error.go b/error.go new file mode 100644 index 0000000..86a6a7e --- /dev/null +++ b/error.go @@ -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) +} diff --git a/error_test.go b/error_test.go new file mode 100644 index 0000000..1f0d6ab --- /dev/null +++ b/error_test.go @@ -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) + }) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..91f9554 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..08dcf93 --- /dev/null +++ b/go.sum @@ -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= diff --git a/helpers.go b/helpers.go new file mode 100644 index 0000000..a58f375 --- /dev/null +++ b/helpers.go @@ -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 +} diff --git a/helpers_test.go b/helpers_test.go new file mode 100644 index 0000000..b15461c --- /dev/null +++ b/helpers_test.go @@ -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) + }) + } +} diff --git a/validate.go b/validate.go new file mode 100644 index 0000000..45f65ea --- /dev/null +++ b/validate.go @@ -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 +} diff --git a/validate_test.go b/validate_test.go new file mode 100644 index 0000000..3108020 --- /dev/null +++ b/validate_test.go @@ -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) + }) + } +} diff --git a/validator.go b/validator.go new file mode 100644 index 0000000..d171925 --- /dev/null +++ b/validator.go @@ -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, ".") +} diff --git a/validator_test.go b/validator_test.go new file mode 100644 index 0000000..bf47c3a --- /dev/null +++ b/validator_test.go @@ -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: ".", 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) +}