diff --git a/.changelog/985.txt b/.changelog/985.txt new file mode 100644 index 00000000000..d76208dd2f2 --- /dev/null +++ b/.changelog/985.txt @@ -0,0 +1,11 @@ +```release-note:note +helper/validation: The `StringLenBetween()` function is being deprecated in favor of the `StringBytesBetween()` function +``` + +```release-note:feature +helper/validation: Added `StringRuneCountBetween()` function for validate string with `number of characters` +``` + +```release-note:enhancement +helper/validation: Added validation for parameters at `StringLenBetween()` function +``` diff --git a/helper/validation/strings.go b/helper/validation/strings.go index e739a1a1bfa..c837c02ce61 100644 --- a/helper/validation/strings.go +++ b/helper/validation/strings.go @@ -5,6 +5,7 @@ import ( "fmt" "regexp" "strings" + "unicode/utf8" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/structure" @@ -66,10 +67,30 @@ func StringIsWhiteSpace(i interface{}, k string) ([]string, []error) { return nil, nil } +// Deprecated: Use StringBytesBetween() instead. +// **Recommend StringRuneCountBetween()** in order to count 'String length' correctly // StringLenBetween returns a SchemaValidateFunc which tests if the provided value -// is of type string and has length between min and max (inclusive) +// is of type string and has 'Byte' length between min and max (inclusive) func StringLenBetween(min, max int) schema.SchemaValidateFunc { + return StringBytesBetween(min, max) +} + +// StringBytesBetween returns a SchemaValidateFunc which tests if the provided value +// is of type string and has 'Byte' length between min and max (inclusive) +// **Recommend StringRuneCountBetween()** in order to count 'String length' correctly +func StringBytesBetween(min, max int) schema.SchemaValidateFunc { return func(i interface{}, k string) (warnings []string, errors []error) { + + if min < 0 { + errors = append(errors, fmt.Errorf("min must be zero or natural number (actual: %d)", min)) + } + if max < 0 { + errors = append(errors, fmt.Errorf("max must be zero or natural number (actual: %d)", max)) + } + if min > max { + errors = append(errors, fmt.Errorf("min must be less than or equal to max (actual: min=%d, max=%d)", min, max)) + } + v, ok := i.(string) if !ok { errors = append(errors, fmt.Errorf("expected type of %s to be string", k)) @@ -84,6 +105,35 @@ func StringLenBetween(min, max int) schema.SchemaValidateFunc { } } +// StringRuneCountBetween returns a SchemaValidateFunc which tests if the provided value +// is of type string and has 'Rune' length between min and max (inclusive) +func StringRuneCountBetween(min, max int) schema.SchemaValidateFunc { + return func(i interface{}, k string) (warnings []string, errors []error) { + + if min < 0 { + errors = append(errors, fmt.Errorf("min must be zero or natural number (actual: %d)", min)) + } + if max < 0 { + errors = append(errors, fmt.Errorf("max must be zero or natural number (actual: %d)", max)) + } + if min > max { + errors = append(errors, fmt.Errorf("min must be less than or equal to max (actual: min=%d, max=%d)", min, max)) + } + + v, ok := i.(string) + if !ok { + errors = append(errors, fmt.Errorf("expected type of %s to be string", k)) + return warnings, errors + } + + if c := utf8.RuneCountInString(v); c < min || c > max { + errors = append(errors, fmt.Errorf("expected length of %s to be in the range (%d - %d), got %s", k, min, max, v)) + } + + return warnings, errors + } +} + // StringMatch returns a SchemaValidateFunc which tests if the provided value // matches a given regexp. Optionally an error message can be provided to // return something friendlier than "must match some globby regexp". diff --git a/helper/validation/strings_test.go b/helper/validation/strings_test.go index 61ae6b4e699..c0dbb9bff7b 100644 --- a/helper/validation/strings_test.go +++ b/helper/validation/strings_test.go @@ -240,6 +240,344 @@ func TestValidationStringIsWhiteSpace(t *testing.T) { } } +func TestValidationStringBytesBetween(t *testing.T) { + cases := map[string]struct { + Value interface{} + Min int + Max int + Error bool + }{ + "NotStringNil": { + Value: nil, + Error: true, + }, + "NotStringBool": { + Value: bool(true), + Error: true, + }, + "NotStringInt": { + Value: int(-1), + Error: true, + }, + "NotStringUint": { + Value: uint(1), + Error: true, + }, + "NotStringByteSlice": { + Value: []byte("hello"), + Error: true, + }, + "NotStringRuneSlice": { + Value: []rune("こんにちは"), + Error: true, + }, + "NotStringFloat32": { + Value: float32(1.23), + Error: true, + }, + "NotStringFloat64": { + Value: float32(-1.23), + Error: true, + }, + "MinNegativeNumber": { + Value: "MinNegativeNumber", + Min: -1, + Max: 17, + Error: true, + }, + "MinZero": { + Value: "MinZero", + Min: 0, + Max: 7, + Error: false, + }, + "MinPositiveNumber": { + Value: "MinPositiveNumber", + Min: 1, + Max: 17, + Error: false, + }, + "MaxNegativeNumber": { + Value: "MaxNegativeNumber", + Min: 0, + Max: -1, + Error: true, + }, + "MaxZero": { + Value: "", + Min: 0, + Max: 0, + Error: false, + }, + "MaxPositiveNumber": { + Value: "MaxPositiveNumber", + Min: 17, + Max: 17, + Error: false, + }, + "MinLowerThanByteLength": { + Value: "MinLowerThanByteLength", + Min: 21, + Max: 2147483647, + Error: false, + }, + "MinEqualByteLength": { + Value: "MinEqualByteLength", + Min: 18, + Max: 2147483647, + Error: false, + }, + "MinGreaterThanByteLength": { + Value: "MinGreaterThanByteLength", + Min: 25, + Max: 2147483647, + Error: true, + }, + "MaxLowerThanByteLength": { + Value: "MaxLowerThanByteLength", + Min: 0, + Max: 21, + Error: true, + }, + "MaxEqualByteLength": { + Value: "MaxEqualByteLength", + Min: 0, + Max: 18, + Error: false, + }, + "MaxGreaterThanByteLength": { + Value: "MaxGreaterThanByteLength", + Min: 0, + Max: 25, + Error: false, + }, + "Empty": { + Value: "", + Min: 0, + Max: 0, + Error: false, + }, + "WhiteSpace": { + Value: " ", + Min: 1, + Max: 1, + Error: false, + }, + "Tab": { + Value: "\t", + Min: 1, + Max: 1, + Error: false, + }, + "1ByteString": { + Value: "Hello world!", + Min: 12, + Max: 12, + Error: false, + }, + "2BytesString": { + Value: "αβγ", + Min: 6, + Max: 6, + Error: false, + }, + "3BytesString": { + Value: "こんにちは世界!", + Min: 24, + Max: 24, + Error: false, + }, + "4BytesString": { + Value: "👍", + Min: 4, + Max: 4, + Error: false, + }, + } + + for tn, tc := range cases { + t.Run(tn, func(t *testing.T) { + v := StringBytesBetween(tc.Min, tc.Max) + _, errors := v(tc.Value, tn) + + if len(errors) > 0 && !tc.Error { + t.Errorf("StringBytesBetween(%d, %d) with '%v' produced an unexpected error", tc.Min, tc.Max, tc.Value) + } else if len(errors) == 0 && tc.Error { + t.Errorf("StringBytesBetween(%d, %d) with '%v' did not error", tc.Min, tc.Max, tc.Value) + } + }) + } +} + +func TestValidationStringRuneCountBetween(t *testing.T) { + cases := map[string]struct { + Value interface{} + Min int + Max int + Error bool + }{ + "NotStringNil": { + Value: nil, + Error: true, + }, + "NotStringBool": { + Value: bool(true), + Error: true, + }, + "NotStringInt": { + Value: int(-1), + Error: true, + }, + "NotStringUint": { + Value: uint(1), + Error: true, + }, + "NotStringByteSlice": { + Value: []byte("hello"), + Error: true, + }, + "NotStringRuneSlice": { + Value: []rune("こんにちは"), + Error: true, + }, + "NotStringFloat32": { + Value: float32(1.23), + Error: true, + }, + "NotStringFloat64": { + Value: float32(-1.23), + Error: true, + }, + "MinNegativeNumber": { + Value: "MinNegativeNumber", + Min: -1, + Max: 17, + Error: true, + }, + "MinZero": { + Value: "MinZero", + Min: 0, + Max: 7, + Error: false, + }, + "MinPositiveNumber": { + Value: "MinPositiveNumber", + Min: 1, + Max: 17, + Error: false, + }, + "MaxNegativeNumber": { + Value: "MaxNegativeNumber", + Min: 0, + Max: -1, + Error: true, + }, + "MaxZero": { + Value: "", + Min: 0, + Max: 0, + Error: false, + }, + "MaxPositiveNumber": { + Value: "MaxPositiveNumber", + Min: 17, + Max: 17, + Error: false, + }, + "MinLowerThanByteLength": { + Value: "MinLowerThanByteLength", + Min: 21, + Max: 2147483647, + Error: false, + }, + "MinEqualByteLength": { + Value: "MinEqualByteLength", + Min: 18, + Max: 2147483647, + Error: false, + }, + "MinGreaterThanByteLength": { + Value: "MinGreaterThanByteLength", + Min: 25, + Max: 2147483647, + Error: true, + }, + "MaxLowerThanByteLength": { + Value: "MaxLowerThanByteLength", + Min: 0, + Max: 21, + Error: true, + }, + "MaxEqualByteLength": { + Value: "MaxEqualByteLength", + Min: 0, + Max: 18, + Error: false, + }, + "MaxGreaterThanByteLength": { + Value: "MaxGreaterThanByteLength", + Min: 0, + Max: 25, + Error: false, + }, + "Empty": { + Value: "", + Min: 0, + Max: 0, + Error: false, + }, + "WhiteSpace": { + Value: " ", + Min: 1, + Max: 1, + Error: false, + }, + "Tab": { + Value: "\t", + Min: 1, + Max: 1, + Error: false, + }, + "1ByteString": { + Value: "Hello world!", + Min: 12, + Max: 12, + Error: false, + }, + "2BytesString": { + Value: "αβγ", + Min: 3, + Max: 3, + Error: false, + }, + "3BytesString": { + Value: "こんにちは世界!", + Min: 8, + Max: 8, + Error: false, + }, + "4BytesString": { + Value: "👍", + Min: 1, + Max: 1, + Error: false, + }, + } + + for tn, tc := range cases { + t.Run(tn, func(t *testing.T) { + v := StringRuneCountBetween(tc.Min, tc.Max) + _, errors := v(tc.Value, tn) + + if len(errors) > 0 && !tc.Error { + t.Errorf("StringRuneCountBetween(%d, %d) with '%v' produced an unexpected error", tc.Min, tc.Max, tc.Value) + } else if len(errors) == 0 && tc.Error { + t.Errorf("StringRuneCountBetween(%d, %d) with '%v' did not error", tc.Min, tc.Max, tc.Value) + } + }) + } +} + func TestValidationStringIsBase64(t *testing.T) { cases := map[string]struct { Value interface{}