Skip to content

Commit

Permalink
Validate untyped maps similar to structs (#128)
Browse files Browse the repository at this point in the history
* Validate untyped maps similar to structs

* Map validation refinements based on feedback

* Flag map keys as optional
  • Loading branch information
NathanBaulch authored Oct 19, 2020
1 parent 25eba0b commit c0d3bb3
Show file tree
Hide file tree
Showing 4 changed files with 346 additions and 0 deletions.
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,53 @@ And when each field is validated, its rules are also evaluated in the order they
If a rule fails, an error is recorded for that field, and the validation will continue with the next field.


### Validating a Map

Sometimes you might need to work with dynamic data stored in maps rather than a typed model. You can use `validation.Map()`
in this situation. A single map can have rules for multiple keys, and a key can be associated with multiple
rules. For example,

```go
c := map[string]interface{}{
"Name": "Qiang Xue",
"Email": "q",
"Address": map[string]interface{}{
"Street": "123",
"City": "Unknown",
"State": "Virginia",
"Zip": "12345",
},
}

err := validation.Validate(c,
validation.Map(
// Name cannot be empty, and the length must be between 5 and 20.
validation.Key("Name", validation.Required, validation.Length(5, 20)),
// Email cannot be empty and should be in a valid email format.
validation.Key("Email", validation.Required, is.Email),
// Validate Address using its own validation rules
validation.Key("Address", validation.Map(
// Street cannot be empty, and the length must between 5 and 50
validation.Key("Street", validation.Required, validation.Length(5, 50)),
// City cannot be empty, and the length must between 5 and 50
validation.Key("City", validation.Required, validation.Length(5, 50)),
// State cannot be empty, and must be a string consisting of two letters in upper case
validation.Key("State", validation.Required, validation.Match(regexp.MustCompile("^[A-Z]{2}$"))),
// State cannot be empty, and must be a string consisting of five digits
validation.Key("Zip", validation.Required, validation.Match(regexp.MustCompile("^[0-9]{5}$"))),
)),
),
)
fmt.Println(err)
// Output:
// Address: (State: must be in a valid format; Street: the length must be between 5 and 50.); Email: must be a valid email address.
```

When the map validation is performed, the keys are validated in the order they are specified in `Map`.
And when each key is validated, its rules are also evaluated in the order they are associated with the key.
If a rule fails, an error is recorded for that key, and the validation will continue with the next key.


### Validation Errors

The `validation.ValidateStruct` method returns validation errors found in struct fields in terms of `validation.Errors`
Expand Down
36 changes: 36 additions & 0 deletions example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,3 +154,39 @@ func Example_six() {
// unexpected value
// <nil>
}

func Example_seven() {
c := map[string]interface{}{
"Name": "Qiang Xue",
"Email": "q",
"Address": map[string]interface{}{
"Street": "123",
"City": "Unknown",
"State": "Virginia",
"Zip": "12345",
},
}

err := validation.Validate(c,
validation.Map(
// Name cannot be empty, and the length must be between 5 and 20.
validation.Key("Name", validation.Required, validation.Length(5, 20)),
// Email cannot be empty and should be in a valid email format.
validation.Key("Email", validation.Required, is.Email),
// Validate Address using its own validation rules
validation.Key("Address", validation.Map(
// Street cannot be empty, and the length must between 5 and 50
validation.Key("Street", validation.Required, validation.Length(5, 50)),
// City cannot be empty, and the length must between 5 and 50
validation.Key("City", validation.Required, validation.Length(5, 50)),
// State cannot be empty, and must be a string consisting of two letters in upper case
validation.Key("State", validation.Required, validation.Match(regexp.MustCompile("^[A-Z]{2}$"))),
// State cannot be empty, and must be a string consisting of five digits
validation.Key("Zip", validation.Required, validation.Match(regexp.MustCompile("^[0-9]{5}$"))),
)),
),
)
fmt.Println(err)
// Output:
// Address: (State: must be in a valid format; Street: the length must be between 5 and 50.); Email: must be a valid email address.
}
144 changes: 144 additions & 0 deletions map.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package validation

import (
"context"
"errors"
"fmt"
"reflect"
)

var (
// ErrNotMap is the error that the value being validated is not a map.
ErrNotMap = errors.New("only a map can be validated")

// ErrKeyWrongType is the error returned in case of an incorrect key type.
ErrKeyWrongType = NewError("validation_key_wrong_type", "key not the correct type")

// ErrKeyMissing is the error returned in case of a missing key.
ErrKeyMissing = NewError("validation_key_missing", "required key is missing")

// ErrKeyUnexpected is the error returned in case of an unexpected key.
ErrKeyUnexpected = NewError("validation_key_unexpected", "key not expected")
)

type (
// MapRule represents a rule set associated with a map.
MapRule struct {
keys []*KeyRules
allowExtraKeys bool
}

// KeyRules represents a rule set associated with a map key.
KeyRules struct {
key interface{}
optional bool
rules []Rule
}
)

// Map returns a validation rule that checks the keys and values of a map.
// This rule should only be used for validating maps, or a validation error will be reported.
// Use Key() to specify map keys that need to be validated. Each Key() call specifies a single key which can
// be associated with multiple rules.
// For example,
// validation.Map(
// validation.Key("Name", validation.Required),
// validation.Key("Value", validation.Required, validation.Length(5, 10)),
// )
//
// A nil value is considered valid. Use the Required rule to make sure a map value is present.
func Map(keys ...*KeyRules) MapRule {
return MapRule{keys: keys}
}

// AllowExtraKeys configures the rule to ignore extra keys.
func (r MapRule) AllowExtraKeys() MapRule {
r.allowExtraKeys = true
return r
}

// Validate checks if the given value is valid or not.
func (r MapRule) Validate(m interface{}) error {
return r.ValidateWithContext(nil, m)
}

// ValidateWithContext checks if the given value is valid or not.
func (r MapRule) ValidateWithContext(ctx context.Context, m interface{}) error {
value := reflect.ValueOf(m)
if value.Kind() == reflect.Ptr {
value = value.Elem()
}
if value.Kind() != reflect.Map {
// must be a map
return NewInternalError(ErrNotMap)
}
if value.IsNil() {
// treat a nil map as valid
return nil
}

errs := Errors{}
kt := value.Type().Key()

var extraKeys map[interface{}]bool
if !r.allowExtraKeys {
extraKeys = make(map[interface{}]bool, value.Len())
for _, k := range value.MapKeys() {
extraKeys[k.Interface()] = true
}
}

for _, kr := range r.keys {
var err error
if kv := reflect.ValueOf(kr.key); !kt.AssignableTo(kv.Type()) {
err = ErrKeyWrongType
} else if vv := value.MapIndex(kv); !vv.IsValid() {
if !kr.optional {
err = ErrKeyMissing
}
} else if ctx == nil {
err = Validate(vv.Interface(), kr.rules...)
} else {
err = ValidateWithContext(ctx, vv.Interface(), kr.rules...)
}
if err != nil {
if ie, ok := err.(InternalError); ok && ie.InternalError() != nil {
return err
}
errs[getErrorKeyName(kr.key)] = err
}
if !r.allowExtraKeys {
delete(extraKeys, kr.key)
}
}

if !r.allowExtraKeys {
for key := range extraKeys {
errs[getErrorKeyName(key)] = ErrKeyUnexpected
}
}

if len(errs) > 0 {
return errs
}
return nil
}

// Key specifies a map key and the corresponding validation rules.
func Key(key interface{}, rules ...Rule) *KeyRules {
return &KeyRules{
key: key,
rules: rules,
}
}

// Optional configures the rule to ignore the key if missing.
func (r *KeyRules) Optional() *KeyRules {
r.optional = true
return r
}

// getErrorKeyName returns the name that should be used to represent the validation error of a map key.
func getErrorKeyName(key interface{}) string {
return fmt.Sprintf("%v", key)
}
119 changes: 119 additions & 0 deletions map_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package validation

import (
"context"
"testing"

"github.com/stretchr/testify/assert"
)

func TestMap(t *testing.T) {
var m0 map[string]interface{}
m1 := map[string]interface{}{"A": "abc", "B": "xyz", "c": "abc", "D": (*string)(nil), "F": (*String123)(nil), "H": []string{"abc", "abc"}, "I": map[string]string{"foo": "abc"}}
m2 := map[string]interface{}{"E": String123("xyz"), "F": (*String123)(nil)}
m3 := map[string]interface{}{"M3": Model3{}}
m4 := map[string]interface{}{"M3": Model3{A: "abc"}}
m5 := map[string]interface{}{"A": "internal", "B": ""}
m6 := map[int]string{11: "abc", 22: "xyz"}
tests := []struct {
tag string
model interface{}
rules []*KeyRules
err string
}{
// empty rules
{"t1.1", m1, []*KeyRules{}, ""},
{"t1.2", m1, []*KeyRules{Key("A"), Key("B")}, ""},
// normal rules
{"t2.1", m1, []*KeyRules{Key("A", &validateAbc{}), Key("B", &validateXyz{})}, ""},
{"t2.2", m1, []*KeyRules{Key("A", &validateXyz{}), Key("B", &validateAbc{})}, "A: error xyz; B: error abc."},
{"t2.3", m1, []*KeyRules{Key("A", &validateXyz{}), Key("c", &validateXyz{})}, "A: error xyz; c: error xyz."},
{"t2.4", m1, []*KeyRules{Key("D", Length(0, 5))}, ""},
{"t2.5", m1, []*KeyRules{Key("F", Length(0, 5))}, ""},
{"t2.6", m1, []*KeyRules{Key("H", Each(&validateAbc{})), Key("I", Each(&validateAbc{}))}, ""},
{"t2.7", m1, []*KeyRules{Key("H", Each(&validateXyz{})), Key("I", Each(&validateXyz{}))}, "H: (0: error xyz; 1: error xyz.); I: (foo: error xyz.)."},
{"t2.8", m1, []*KeyRules{Key("I", Map(Key("foo", &validateAbc{})))}, ""},
{"t2.9", m1, []*KeyRules{Key("I", Map(Key("foo", &validateXyz{})))}, "I: (foo: error xyz.)."},
// non-map value
{"t3.1", &m1, []*KeyRules{}, ""},
{"t3.2", nil, []*KeyRules{}, ErrNotMap.Error()},
{"t3.3", m0, []*KeyRules{}, ""},
{"t3.4", &m0, []*KeyRules{}, ""},
{"t3.5", 123, []*KeyRules{}, ErrNotMap.Error()},
// invalid key spec
{"t4.1", m1, []*KeyRules{Key(123)}, "123: key not the correct type."},
{"t4.2", m1, []*KeyRules{Key("X")}, "X: required key is missing."},
{"t4.3", m1, []*KeyRules{Key("X").Optional()}, ""},
// non-string keys
{"t5.1", m6, []*KeyRules{Key(11, &validateAbc{}), Key(22, &validateXyz{})}, ""},
{"t5.2", m6, []*KeyRules{Key(11, &validateXyz{}), Key(22, &validateAbc{})}, "11: error xyz; 22: error abc."},
// validatable value
{"t6.1", m2, []*KeyRules{Key("E")}, "E: error 123."},
{"t6.2", m2, []*KeyRules{Key("E", Skip)}, ""},
{"t6.3", m2, []*KeyRules{Key("E", Skip.When(true))}, ""},
{"t6.4", m2, []*KeyRules{Key("E", Skip.When(false))}, "E: error 123."},
// Required, NotNil
{"t7.1", m2, []*KeyRules{Key("F", Required)}, "F: cannot be blank."},
{"t7.2", m2, []*KeyRules{Key("F", NotNil)}, "F: is required."},
{"t7.3", m2, []*KeyRules{Key("F", Skip, Required)}, ""},
{"t7.4", m2, []*KeyRules{Key("F", Skip, NotNil)}, ""},
{"t7.5", m2, []*KeyRules{Key("F", Skip.When(true), Required)}, ""},
{"t7.6", m2, []*KeyRules{Key("F", Skip.When(true), NotNil)}, ""},
{"t7.7", m2, []*KeyRules{Key("F", Skip.When(false), Required)}, "F: cannot be blank."},
{"t7.8", m2, []*KeyRules{Key("F", Skip.When(false), NotNil)}, "F: is required."},
// validatable structs
{"t8.1", m3, []*KeyRules{Key("M3", Skip)}, ""},
{"t8.2", m3, []*KeyRules{Key("M3")}, "M3: (A: error abc.)."},
{"t8.3", m4, []*KeyRules{Key("M3")}, ""},
// internal error
{"t9.1", m5, []*KeyRules{Key("A", &validateAbc{}), Key("B", Required), Key("A", &validateInternalError{})}, "error internal"},
}
for _, test := range tests {
err1 := Validate(test.model, Map(test.rules...).AllowExtraKeys())
err2 := ValidateWithContext(context.Background(), test.model, Map(test.rules...).AllowExtraKeys())
assertError(t, test.err, err1, test.tag)
assertError(t, test.err, err2, test.tag)
}

a := map[string]interface{}{"Name": "name", "Value": "demo", "Extra": true}
err := Validate(a, Map(
Key("Name", Required),
Key("Value", Required, Length(5, 10)),
))
assert.EqualError(t, err, "Extra: key not expected; Value: the length must be between 5 and 10.")
}

func TestMapWithContext(t *testing.T) {
m1 := map[string]interface{}{"A": "abc", "B": "xyz", "c": "abc", "g": "xyz"}
m2 := map[string]interface{}{"A": "internal", "B": ""}
tests := []struct {
tag string
model interface{}
rules []*KeyRules
err string
}{
// normal rules
{"t1.1", m1, []*KeyRules{Key("A", &validateContextAbc{}), Key("B", &validateContextXyz{})}, ""},
{"t1.2", m1, []*KeyRules{Key("A", &validateContextXyz{}), Key("B", &validateContextAbc{})}, "A: error xyz; B: error abc."},
{"t1.3", m1, []*KeyRules{Key("A", &validateContextXyz{}), Key("c", &validateContextXyz{})}, "A: error xyz; c: error xyz."},
{"t1.4", m1, []*KeyRules{Key("g", &validateContextAbc{})}, "g: error abc."},
// skip rule
{"t2.1", m1, []*KeyRules{Key("g", Skip, &validateContextAbc{})}, ""},
{"t2.2", m1, []*KeyRules{Key("g", &validateContextAbc{}, Skip)}, "g: error abc."},
// internal error
{"t3.1", m2, []*KeyRules{Key("A", &validateContextAbc{}), Key("B", Required), Key("A", &validateInternalError{})}, "error internal"},
}
for _, test := range tests {
err := ValidateWithContext(context.Background(), test.model, Map(test.rules...).AllowExtraKeys())
assertError(t, test.err, err, test.tag)
}

a := map[string]interface{}{"Name": "name", "Value": "demo", "Extra": true}
err := ValidateWithContext(context.Background(), a, Map(
Key("Name", Required),
Key("Value", Required, Length(5, 10)),
))
if assert.NotNil(t, err) {
assert.Equal(t, "Extra: key not expected; Value: the length must be between 5 and 10.", err.Error())
}
}

0 comments on commit c0d3bb3

Please sign in to comment.