diff --git a/.changelog/42.txt b/.changelog/42.txt index ff2acc66..1aaaafb9 100644 --- a/.changelog/42.txt +++ b/.changelog/42.txt @@ -9,7 +9,3 @@ int64validator: 2 new validation functions, `OneOf()` and `NoneOf()` ```release-note:feature numbervalidator: New package that starts with 2 validation functions, `OneOf()` and `NoneOf()` ``` - -```release-note:enhancement -stringvalidator: 2 new validation functions, `OneOf()` and `NoneOf()`, that offer case-sensitivity control -``` diff --git a/.changelog/45.txt b/.changelog/45.txt new file mode 100644 index 00000000..7b7800a8 --- /dev/null +++ b/.changelog/45.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +stringvalidator: 4 new validation functions, `OneOf()` and `NoneOf()` (case sensitive), and `OneOfCaseInsensitive()` and `NoneOfCaseInsensitive()` (case insensitive) +``` diff --git a/stringvalidator/acceptable_strings_validator.go b/stringvalidator/acceptable_strings_validator.go index 7f1e9592..e0a302b3 100644 --- a/stringvalidator/acceptable_strings_validator.go +++ b/stringvalidator/acceptable_strings_validator.go @@ -9,20 +9,19 @@ import ( "github.com/hashicorp/terraform-plugin-framework/tfsdk" ) -// acceptableStringsAttributeValidator is the underlying struct implementing OneOf and NoneOf. -type acceptableStringsAttributeValidator struct { +// acceptableStringsCaseInsensitiveAttributeValidator is the underlying struct implementing OneOf and NoneOf. +type acceptableStringsCaseInsensitiveAttributeValidator struct { acceptableStrings []string - caseSensitive bool shouldMatch bool } -var _ tfsdk.AttributeValidator = (*acceptableStringsAttributeValidator)(nil) +var _ tfsdk.AttributeValidator = (*acceptableStringsCaseInsensitiveAttributeValidator)(nil) -func (av *acceptableStringsAttributeValidator) Description(ctx context.Context) string { +func (av *acceptableStringsCaseInsensitiveAttributeValidator) Description(ctx context.Context) string { return av.MarkdownDescription(ctx) } -func (av *acceptableStringsAttributeValidator) MarkdownDescription(_ context.Context) string { +func (av *acceptableStringsCaseInsensitiveAttributeValidator) MarkdownDescription(_ context.Context) string { if av.shouldMatch { return fmt.Sprintf("String must match one of: %q", av.acceptableStrings) } else { @@ -30,7 +29,7 @@ func (av *acceptableStringsAttributeValidator) MarkdownDescription(_ context.Con } } -func (av *acceptableStringsAttributeValidator) Validate(ctx context.Context, req tfsdk.ValidateAttributeRequest, res *tfsdk.ValidateAttributeResponse) { +func (av *acceptableStringsCaseInsensitiveAttributeValidator) Validate(ctx context.Context, req tfsdk.ValidateAttributeRequest, res *tfsdk.ValidateAttributeResponse) { value, ok := validateString(ctx, req, res) if !ok { return @@ -46,16 +45,10 @@ func (av *acceptableStringsAttributeValidator) Validate(ctx context.Context, req } } -func (av *acceptableStringsAttributeValidator) isAcceptableValue(v string) bool { +func (av *acceptableStringsCaseInsensitiveAttributeValidator) isAcceptableValue(v string) bool { for _, acceptableV := range av.acceptableStrings { - if av.caseSensitive { - if v == acceptableV { - return true - } - } else { - if strings.EqualFold(v, acceptableV) { - return true - } + if strings.EqualFold(v, acceptableV) { + return true } } diff --git a/stringvalidator/none_of.go b/stringvalidator/none_of.go index 0fbc057a..c444e283 100644 --- a/stringvalidator/none_of.go +++ b/stringvalidator/none_of.go @@ -1,17 +1,28 @@ package stringvalidator import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/primitivevalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" ) // NoneOf checks that the string held in the attribute // is none of the given `unacceptableStrings`. -// -// String comparison case sensitiveness is controlled by the `caseSensitive` argument. -func NoneOf(caseSensitive bool, unacceptableStrings ...string) tfsdk.AttributeValidator { - return &acceptableStringsAttributeValidator{ +func NoneOf(unacceptableStrings ...string) tfsdk.AttributeValidator { + unacceptableStringValues := make([]attr.Value, 0, len(unacceptableStrings)) + for _, s := range unacceptableStrings { + unacceptableStringValues = append(unacceptableStringValues, types.String{Value: s}) + } + + return primitivevalidator.NoneOf(unacceptableStringValues...) +} + +// NoneOfCaseInsensitive checks that the string held in the attribute +// is none of the given `unacceptableStrings`, irrespective of case sensitivity. +func NoneOfCaseInsensitive(unacceptableStrings ...string) tfsdk.AttributeValidator { + return &acceptableStringsCaseInsensitiveAttributeValidator{ unacceptableStrings, - caseSensitive, false, } } diff --git a/stringvalidator/none_of_test.go b/stringvalidator/none_of_test.go index 536640cb..78446222 100644 --- a/stringvalidator/none_of_test.go +++ b/stringvalidator/none_of_test.go @@ -30,27 +30,24 @@ func TestNoneOfValidator(t *testing.T) { "simple-match": { in: types.String{Value: "foo"}, validator: stringvalidator.NoneOf( - true, "foo", "bar", "baz", ), expErrors: 1, }, - "simple-match-case-insensitive": { + "simple-mismatch-case-insensitive": { in: types.String{Value: "foo"}, validator: stringvalidator.NoneOf( - false, "FOO", "bar", "baz", ), - expErrors: 1, + expErrors: 0, }, "simple-mismatch": { in: types.String{Value: "foz"}, validator: stringvalidator.NoneOf( - true, "foo", "bar", "baz", @@ -67,7 +64,6 @@ func TestNoneOfValidator(t *testing.T) { }, }, validator: stringvalidator.NoneOf( - true, "10", "20", "30", @@ -86,7 +82,6 @@ func TestNoneOfValidator(t *testing.T) { }, }, validator: stringvalidator.NoneOf( - true, "bob", "alice", "john", @@ -106,7 +101,6 @@ func TestNoneOfValidator(t *testing.T) { }, }, validator: stringvalidator.NoneOf( - true, "1.1", "10.20", "5.4", @@ -125,7 +119,6 @@ func TestNoneOfValidator(t *testing.T) { }, }, validator: stringvalidator.NoneOf( - true, "Bob Parr", "40", "1200 Park Avenue Emeryville", @@ -136,7 +129,6 @@ func TestNoneOfValidator(t *testing.T) { "skip-validation-on-null": { in: types.String{Null: true}, validator: stringvalidator.NoneOf( - true, "foo", "bar", "baz", @@ -146,7 +138,165 @@ func TestNoneOfValidator(t *testing.T) { "skip-validation-on-unknown": { in: types.String{Unknown: true}, validator: stringvalidator.NoneOf( - true, + "foo", + "bar", + "baz", + ), + expErrors: 0, + }, + } + + for name, test := range testCases { + name, test := name, test + t.Run(name, func(t *testing.T) { + req := tfsdk.ValidateAttributeRequest{ + AttributeConfig: test.in, + } + res := tfsdk.ValidateAttributeResponse{} + test.validator.Validate(context.TODO(), req, &res) + + if test.expErrors > 0 && !res.Diagnostics.HasError() { + t.Fatalf("expected %d error(s), got none", test.expErrors) + } + + if test.expErrors > 0 && test.expErrors != validatordiag.ErrorsCount(res.Diagnostics) { + t.Fatalf("expected %d error(s), got %d: %v", test.expErrors, validatordiag.ErrorsCount(res.Diagnostics), res.Diagnostics) + } + + if test.expErrors == 0 && res.Diagnostics.HasError() { + t.Fatalf("expected no error(s), got %d: %v", validatordiag.ErrorsCount(res.Diagnostics), res.Diagnostics) + } + }) + } +} + +func TestNoneOfCaseInsensitiveValidator(t *testing.T) { + t.Parallel() + + type testCase struct { + in attr.Value + validator tfsdk.AttributeValidator + expErrors int + } + + objAttrTypes := map[string]attr.Type{ + "Name": types.StringType, + "Age": types.StringType, + "Address": types.StringType, + } + + testCases := map[string]testCase{ + "simple-match": { + in: types.String{Value: "foo"}, + validator: stringvalidator.NoneOfCaseInsensitive( + "foo", + "bar", + "baz", + ), + expErrors: 1, + }, + "simple-match-case-insensitive": { + in: types.String{Value: "foo"}, + validator: stringvalidator.NoneOfCaseInsensitive( + "FOO", + "bar", + "baz", + ), + expErrors: 1, + }, + "simple-mismatch": { + in: types.String{Value: "foz"}, + validator: stringvalidator.NoneOfCaseInsensitive( + "foo", + "bar", + "baz", + ), + expErrors: 0, + }, + "list-not-allowed": { + in: types.List{ + ElemType: types.StringType, + Elems: []attr.Value{ + types.String{Value: "10"}, + types.String{Value: "20"}, + types.String{Value: "30"}, + }, + }, + validator: stringvalidator.NoneOfCaseInsensitive( + "10", + "20", + "30", + "40", + "50", + ), + expErrors: 1, + }, + "set-not-allowed": { + in: types.Set{ + ElemType: types.StringType, + Elems: []attr.Value{ + types.String{Value: "foo"}, + types.String{Value: "bar"}, + types.String{Value: "baz"}, + }, + }, + validator: stringvalidator.NoneOfCaseInsensitive( + "bob", + "alice", + "john", + "foo", + "bar", + "baz", + ), + expErrors: 1, + }, + "map-not-allowed": { + in: types.Map{ + ElemType: types.StringType, + Elems: map[string]attr.Value{ + "one.one": types.String{Value: "1.1"}, + "ten.twenty": types.String{Value: "10.20"}, + "five.four": types.String{Value: "5.4"}, + }, + }, + validator: stringvalidator.NoneOfCaseInsensitive( + "1.1", + "10.20", + "5.4", + "geronimo", + "bob", + ), + expErrors: 1, + }, + "object-not-allowed": { + in: types.Object{ + AttrTypes: objAttrTypes, + Attrs: map[string]attr.Value{ + "Name": types.String{Value: "Bob Parr"}, + "Age": types.String{Value: "40"}, + "Address": types.String{Value: "1200 Park Avenue Emeryville"}, + }, + }, + validator: stringvalidator.NoneOfCaseInsensitive( + "Bob Parr", + "40", + "1200 Park Avenue Emeryville", + "123", + ), + expErrors: 1, + }, + "skip-validation-on-null": { + in: types.String{Null: true}, + validator: stringvalidator.NoneOfCaseInsensitive( + "foo", + "bar", + "baz", + ), + expErrors: 0, + }, + "skip-validation-on-unknown": { + in: types.String{Unknown: true}, + validator: stringvalidator.NoneOfCaseInsensitive( "foo", "bar", "baz", diff --git a/stringvalidator/one_of.go b/stringvalidator/one_of.go index d02df037..5b41ad5a 100644 --- a/stringvalidator/one_of.go +++ b/stringvalidator/one_of.go @@ -1,17 +1,28 @@ package stringvalidator import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/primitivevalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" ) // OneOf checks that the string held in the attribute // is one of the given `acceptableStrings`. -// -// String comparison case sensitiveness is controlled by the `caseSensitive` argument. -func OneOf(caseSensitive bool, acceptableStrings ...string) tfsdk.AttributeValidator { - return &acceptableStringsAttributeValidator{ +func OneOf(acceptableStrings ...string) tfsdk.AttributeValidator { + acceptableStringValues := make([]attr.Value, 0, len(acceptableStrings)) + for _, s := range acceptableStrings { + acceptableStringValues = append(acceptableStringValues, types.String{Value: s}) + } + + return primitivevalidator.OneOf(acceptableStringValues...) +} + +// OneOfCaseInsensitive checks that the string held in the attribute +// is one of the given `acceptableStrings`, irrespective of case sensitivity. +func OneOfCaseInsensitive(acceptableStrings ...string) tfsdk.AttributeValidator { + return &acceptableStringsCaseInsensitiveAttributeValidator{ acceptableStrings, - caseSensitive, true, } } diff --git a/stringvalidator/one_of_test.go b/stringvalidator/one_of_test.go index d62b1949..a536d3d8 100644 --- a/stringvalidator/one_of_test.go +++ b/stringvalidator/one_of_test.go @@ -30,27 +30,24 @@ func TestOneOfValidator(t *testing.T) { "simple-match": { in: types.String{Value: "foo"}, validator: stringvalidator.OneOf( - true, "foo", "bar", "baz", ), expErrors: 0, }, - "simple-match-case-insensitive": { + "simple-mismatch-case-insensitive": { in: types.String{Value: "foo"}, validator: stringvalidator.OneOf( - false, "FOO", "bar", "baz", ), - expErrors: 0, + expErrors: 1, }, "simple-mismatch": { in: types.String{Value: "foz"}, validator: stringvalidator.OneOf( - true, "foo", "bar", "baz", @@ -67,7 +64,6 @@ func TestOneOfValidator(t *testing.T) { }, }, validator: stringvalidator.OneOf( - true, "10", "20", "30", @@ -86,7 +82,6 @@ func TestOneOfValidator(t *testing.T) { }, }, validator: stringvalidator.OneOf( - true, "bob", "alice", "john", @@ -106,7 +101,6 @@ func TestOneOfValidator(t *testing.T) { }, }, validator: stringvalidator.OneOf( - true, "1.1", "10.20", "5.4", @@ -125,7 +119,6 @@ func TestOneOfValidator(t *testing.T) { }, }, validator: stringvalidator.OneOf( - true, "Bob Parr", "40", "1200 Park Avenue Emeryville", @@ -136,7 +129,6 @@ func TestOneOfValidator(t *testing.T) { "skip-validation-on-null": { in: types.String{Null: true}, validator: stringvalidator.OneOf( - true, "foo", "bar", "baz", @@ -146,7 +138,165 @@ func TestOneOfValidator(t *testing.T) { "skip-validation-on-unknown": { in: types.String{Unknown: true}, validator: stringvalidator.OneOf( - true, + "foo", + "bar", + "baz", + ), + expErrors: 0, + }, + } + + for name, test := range testCases { + name, test := name, test + t.Run(name, func(t *testing.T) { + req := tfsdk.ValidateAttributeRequest{ + AttributeConfig: test.in, + } + res := tfsdk.ValidateAttributeResponse{} + test.validator.Validate(context.TODO(), req, &res) + + if test.expErrors > 0 && !res.Diagnostics.HasError() { + t.Fatalf("expected %d error(s), got none", test.expErrors) + } + + if test.expErrors > 0 && test.expErrors != validatordiag.ErrorsCount(res.Diagnostics) { + t.Fatalf("expected %d error(s), got %d: %v", test.expErrors, validatordiag.ErrorsCount(res.Diagnostics), res.Diagnostics) + } + + if test.expErrors == 0 && res.Diagnostics.HasError() { + t.Fatalf("expected no error(s), got %d: %v", validatordiag.ErrorsCount(res.Diagnostics), res.Diagnostics) + } + }) + } +} + +func TestOneOfCaseInsensitiveValidator(t *testing.T) { + t.Parallel() + + type testCase struct { + in attr.Value + validator tfsdk.AttributeValidator + expErrors int + } + + objAttrTypes := map[string]attr.Type{ + "Name": types.StringType, + "Age": types.StringType, + "Address": types.StringType, + } + + testCases := map[string]testCase{ + "simple-match": { + in: types.String{Value: "foo"}, + validator: stringvalidator.OneOfCaseInsensitive( + "foo", + "bar", + "baz", + ), + expErrors: 0, + }, + "simple-match-case-insensitive": { + in: types.String{Value: "foo"}, + validator: stringvalidator.OneOfCaseInsensitive( + "FOO", + "bar", + "baz", + ), + expErrors: 0, + }, + "simple-mismatch": { + in: types.String{Value: "foz"}, + validator: stringvalidator.OneOfCaseInsensitive( + "foo", + "bar", + "baz", + ), + expErrors: 1, + }, + "list-not-allowed": { + in: types.List{ + ElemType: types.StringType, + Elems: []attr.Value{ + types.String{Value: "10"}, + types.String{Value: "20"}, + types.String{Value: "30"}, + }, + }, + validator: stringvalidator.OneOfCaseInsensitive( + "10", + "20", + "30", + "40", + "50", + ), + expErrors: 1, + }, + "set-not-allowed": { + in: types.Set{ + ElemType: types.StringType, + Elems: []attr.Value{ + types.String{Value: "foo"}, + types.String{Value: "bar"}, + types.String{Value: "baz"}, + }, + }, + validator: stringvalidator.OneOfCaseInsensitive( + "bob", + "alice", + "john", + "foo", + "bar", + "baz", + ), + expErrors: 1, + }, + "map-not-allowed": { + in: types.Map{ + ElemType: types.StringType, + Elems: map[string]attr.Value{ + "one.one": types.String{Value: "1.1"}, + "ten.twenty": types.String{Value: "10.20"}, + "five.four": types.String{Value: "5.4"}, + }, + }, + validator: stringvalidator.OneOfCaseInsensitive( + "1.1", + "10.20", + "5.4", + "geronimo", + "bob", + ), + expErrors: 1, + }, + "object-not-allowed": { + in: types.Object{ + AttrTypes: objAttrTypes, + Attrs: map[string]attr.Value{ + "Name": types.String{Value: "Bob Parr"}, + "Age": types.String{Value: "40"}, + "Address": types.String{Value: "1200 Park Avenue Emeryville"}, + }, + }, + validator: stringvalidator.OneOfCaseInsensitive( + "Bob Parr", + "40", + "1200 Park Avenue Emeryville", + "123", + ), + expErrors: 1, + }, + "skip-validation-on-null": { + in: types.String{Null: true}, + validator: stringvalidator.OneOfCaseInsensitive( + "foo", + "bar", + "baz", + ), + expErrors: 0, + }, + "skip-validation-on-unknown": { + in: types.String{Unknown: true}, + validator: stringvalidator.OneOfCaseInsensitive( "foo", "bar", "baz",