-
Notifications
You must be signed in to change notification settings - Fork 224
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Validate untyped maps similar to structs (#128)
* Validate untyped maps similar to structs * Map validation refinements based on feedback * Flag map keys as optional
- Loading branch information
1 parent
25eba0b
commit c0d3bb3
Showing
4 changed files
with
346 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()) | ||
} | ||
} |