diff --git a/lang/default.go b/lang/default.go index 8a3384db..b481812c 100644 --- a/lang/default.go +++ b/lang/default.go @@ -189,6 +189,8 @@ var enUS = &Language{ "unique.element": "The :field element value has already been taken.", "exists": "The :field does not exist.", "exists.element": "The :field element value does not exist.", + "keysin": "The :field keys must be one of the following: :values.", + "keysin.element": "The :field elements keys must be one of the following: :values.", "doesnt_end_with": "The :field must not end with any of the following values: :values.", "doesnt_end_with.element": "The :field elements must not end with any of the following values: :values.", }, diff --git a/validation/keysin.go b/validation/keysin.go new file mode 100644 index 00000000..4c79f260 --- /dev/null +++ b/validation/keysin.go @@ -0,0 +1,46 @@ +package validation + +import "strings" + +// KeysInValidator the field under validation must be an object and all its keys must +// be equal to one of the given values. +type KeysInValidator struct { + BaseValidator + Keys []string +} + +// Validate checks the field under validation satisfies this validator's criteria. +func (v *KeysInValidator) Validate(ctx *Context) bool { + obj, ok := ctx.Value.(map[string]any) + if !ok { + return false + } + + allowedKeys := make(map[string]struct{}) + for _, key := range v.Keys { + allowedKeys[key] = struct{}{} + } + + for key := range obj { + if _, ok := allowedKeys[key]; !ok { + return false + } + } + return true +} + +// Name returns the string name of the validator. +func (v *KeysInValidator) Name() string { return "keys_in" } + +// MessagePlaceholders returns the ":values" placeholder. +func (v *KeysInValidator) MessagePlaceholders(_ *Context) []string { + return []string{ + ":values", strings.Join(v.Keys, ", "), + } +} + +// KeysIn the field under validation must be an object and all its keys must +// be equal to one of the given values. +func KeysIn(keys ...string) *KeysInValidator { + return &KeysInValidator{Keys: keys} +} diff --git a/validation/keysin_test.go b/validation/keysin_test.go new file mode 100644 index 00000000..2ad4fe3b --- /dev/null +++ b/validation/keysin_test.go @@ -0,0 +1,47 @@ +package validation + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestKeysInValidator(t *testing.T) { + t.Run("Constructor", func(t *testing.T) { + v := KeysIn("a", "b", "c") + assert.NotNil(t, v) + assert.Equal(t, "keys_in", v.Name()) + assert.False(t, v.IsType()) + assert.False(t, v.IsTypeDependent()) + assert.Equal(t, []string{":values", "a, b, c"}, v.MessagePlaceholders(&Context{})) + }) + + cases := []struct { + input any + allowedKeys []string + want bool + }{ + {allowedKeys: []string{"a", "b", "c"}, input: map[string]any{"a": 1, "b": 2, "c": 3}, want: true}, + {allowedKeys: []string{"a", "b", "c"}, input: map[string]any{"a": 1, "b": 2, "c": 3, "d": 4}, want: false}, + {allowedKeys: []string{"a", "b", "c"}, input: map[string]any{"a": 1, "b": 2}, want: true}, + {allowedKeys: []string{"a", "b", "c"}, input: map[string]any{}, want: true}, + {allowedKeys: []string{"a", "b", "c"}, input: "", want: false}, + {allowedKeys: []string{"a", "b", "c"}, input: 'a', want: false}, + {allowedKeys: []string{"a", "b", "c"}, input: 2, want: false}, + {allowedKeys: []string{"a", "b", "c"}, input: 2.5, want: false}, + {allowedKeys: []string{"a", "b", "c"}, input: true, want: false}, + {allowedKeys: []string{"a", "b", "c"}, input: nil, want: false}, + {allowedKeys: []string{"a", "b", "c"}, input: (map[string]any)(nil), want: true}, + } + + for _, tc := range cases { + tc := tc + t.Run(fmt.Sprintf("Validate_%v_%t", tc.input, tc.want), func(t *testing.T) { + v := KeysIn(tc.allowedKeys...) + assert.Equal(t, tc.want, v.Validate(&Context{ + Value: tc.input, + })) + }) + } +}