diff --git a/.changelog/22.txt b/.changelog/22.txt new file mode 100644 index 00000000..66297384 --- /dev/null +++ b/.changelog/22.txt @@ -0,0 +1,3 @@ +```release-note:feature +Introduced `stringvalidator` package with `LengthAtLeast()`, `LengthAtMost()`, and `LengthBetween()` validation functions +``` \ No newline at end of file diff --git a/stringvalidator/length_at_least.go b/stringvalidator/length_at_least.go new file mode 100644 index 00000000..cbbce1f0 --- /dev/null +++ b/stringvalidator/length_at_least.go @@ -0,0 +1,62 @@ +package stringvalidator + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework-validators/validatordiag" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" +) + +var _ tfsdk.AttributeValidator = lengthAtLeastValidator{} + +// stringLenAtLeastValidator validates that a string Attribute's length is at least a certain value. +type lengthAtLeastValidator struct { + minLength int +} + +// Description describes the validation in plain text formatting. +func (validator lengthAtLeastValidator) Description(_ context.Context) string { + return fmt.Sprintf("string length must be at least %d", validator.minLength) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (validator lengthAtLeastValidator) MarkdownDescription(ctx context.Context) string { + return validator.Description(ctx) +} + +// Validate performs the validation. +func (validator lengthAtLeastValidator) Validate(ctx context.Context, request tfsdk.ValidateAttributeRequest, response *tfsdk.ValidateAttributeResponse) { + s, ok := validateString(ctx, request, response) + + if !ok { + return + } + + if l := len(s); l < validator.minLength { + response.Diagnostics.Append(validatordiag.AttributeValueLengthDiagnostic( + request.AttributePath, + validator.Description(ctx), + fmt.Sprintf("%d", l), + )) + + return + } +} + +// LengthAtLeast returns an AttributeValidator which ensures that any configured +// attribute value: +// +// - Is a string. +// - Is of length exclusively greater than the given minimum. +// +// Null (unconfigured) and unknown (known after apply) values are skipped. +func LengthAtLeast(minLength int) tfsdk.AttributeValidator { + if minLength < 0 { + return nil + } + + return lengthAtLeastValidator{ + minLength: minLength, + } +} diff --git a/stringvalidator/length_at_least_test.go b/stringvalidator/length_at_least_test.go new file mode 100644 index 00000000..3620c6d6 --- /dev/null +++ b/stringvalidator/length_at_least_test.go @@ -0,0 +1,64 @@ +package stringvalidator + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestLengthAtLeastValidator(t *testing.T) { + t.Parallel() + + type testCase struct { + val attr.Value + minLength int + expectError bool + } + tests := map[string]testCase{ + "not a String": { + val: types.Bool{Value: true}, + expectError: true, + }, + "unknown String": { + val: types.String{Unknown: true}, + minLength: 1, + }, + "null String": { + val: types.String{Null: true}, + minLength: 1, + }, + "valid String": { + val: types.String{Value: "ok"}, + minLength: 1, + }, + "too short String": { + val: types.String{Value: ""}, + minLength: 1, + expectError: true, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + request := tfsdk.ValidateAttributeRequest{ + AttributePath: tftypes.NewAttributePath().WithAttributeName("test"), + AttributeConfig: test.val, + } + response := tfsdk.ValidateAttributeResponse{} + LengthAtLeast(test.minLength).Validate(context.TODO(), request, &response) + + if !response.Diagnostics.HasError() && test.expectError { + t.Fatal("expected error, got no error") + } + + if response.Diagnostics.HasError() && !test.expectError { + t.Fatalf("got unexpected error: %s", response.Diagnostics) + } + }) + } +} diff --git a/stringvalidator/length_at_most.go b/stringvalidator/length_at_most.go new file mode 100644 index 00000000..6a7789c5 --- /dev/null +++ b/stringvalidator/length_at_most.go @@ -0,0 +1,82 @@ +package stringvalidator + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework-validators/validatordiag" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var _ tfsdk.AttributeValidator = lengthAtMostValidator{} + +// lengthAtMostValidator validates that a string Attribute's length is at most a certain value. +type lengthAtMostValidator struct { + maxLength int +} + +// Description describes the validation in plain text formatting. +func (validator lengthAtMostValidator) Description(_ context.Context) string { + return fmt.Sprintf("string length must be at most %d", validator.maxLength) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (validator lengthAtMostValidator) MarkdownDescription(ctx context.Context) string { + return validator.Description(ctx) +} + +// Validate performs the validation. +func (validator lengthAtMostValidator) Validate(ctx context.Context, request tfsdk.ValidateAttributeRequest, response *tfsdk.ValidateAttributeResponse) { + s, ok := validateString(ctx, request, response) + + if !ok { + return + } + + if l := len(s); l > validator.maxLength { + response.Diagnostics.Append(validatordiag.AttributeValueLengthDiagnostic( + request.AttributePath, + validator.Description(ctx), + fmt.Sprintf("%d", l), + )) + + return + } +} + +// LengthAtMost returns an AttributeValidator which ensures that any configured +// attribute value: +// +// - Is a string. +// - Is of length exclusively less than the given maximum. +// +// Null (unconfigured) and unknown (known after apply) values are skipped. +func LengthAtMost(maxLength int) tfsdk.AttributeValidator { + if maxLength < 0 { + return nil + } + + return lengthAtMostValidator{ + maxLength: maxLength, + } +} + +// validateString ensures that the request contains a String value. +func validateString(ctx context.Context, request tfsdk.ValidateAttributeRequest, response *tfsdk.ValidateAttributeResponse) (string, bool) { + var s types.String + + diags := tfsdk.ValueAs(ctx, request.AttributeConfig, &s) + + if diags.HasError() { + response.Diagnostics = append(response.Diagnostics, diags...) + + return "", false + } + + if s.Unknown || s.Null { + return "", false + } + + return s.Value, true +} diff --git a/stringvalidator/length_at_most_test.go b/stringvalidator/length_at_most_test.go new file mode 100644 index 00000000..df337644 --- /dev/null +++ b/stringvalidator/length_at_most_test.go @@ -0,0 +1,64 @@ +package stringvalidator + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestLengthAtMostValidator(t *testing.T) { + t.Parallel() + + type testCase struct { + val attr.Value + maxLength int + expectError bool + } + tests := map[string]testCase{ + "not a String": { + val: types.Bool{Value: true}, + expectError: true, + }, + "unknown String": { + val: types.String{Unknown: true}, + maxLength: 1, + }, + "null String": { + val: types.String{Null: true}, + maxLength: 1, + }, + "valid String": { + val: types.String{Value: "ok"}, + maxLength: 2, + }, + "too long String": { + val: types.String{Value: "not ok"}, + maxLength: 5, + expectError: true, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + request := tfsdk.ValidateAttributeRequest{ + AttributePath: tftypes.NewAttributePath().WithAttributeName("test"), + AttributeConfig: test.val, + } + response := tfsdk.ValidateAttributeResponse{} + LengthAtMost(test.maxLength).Validate(context.TODO(), request, &response) + + if !response.Diagnostics.HasError() && test.expectError { + t.Fatal("expected error, got no error") + } + + if response.Diagnostics.HasError() && !test.expectError { + t.Fatalf("got unexpected error: %s", response.Diagnostics) + } + }) + } +} diff --git a/stringvalidator/length_between.go b/stringvalidator/length_between.go new file mode 100644 index 00000000..5656103b --- /dev/null +++ b/stringvalidator/length_between.go @@ -0,0 +1,63 @@ +package stringvalidator + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework-validators/validatordiag" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" +) + +var _ tfsdk.AttributeValidator = lengthBetweenValidator{} + +// stringLenBetweenValidator validates that a string Attribute's length is in a range. +type lengthBetweenValidator struct { + minLength, maxLength int +} + +// Description describes the validation in plain text formatting. +func (validator lengthBetweenValidator) Description(_ context.Context) string { + return fmt.Sprintf("string length must be between %d and %d", validator.minLength, validator.maxLength) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (validator lengthBetweenValidator) MarkdownDescription(ctx context.Context) string { + return validator.Description(ctx) +} + +// Validate performs the validation. +func (validator lengthBetweenValidator) Validate(ctx context.Context, request tfsdk.ValidateAttributeRequest, response *tfsdk.ValidateAttributeResponse) { + s, ok := validateString(ctx, request, response) + + if !ok { + return + } + + if l := len(s); l < validator.minLength || l > validator.maxLength { + response.Diagnostics.Append(validatordiag.AttributeValueLengthDiagnostic( + request.AttributePath, + validator.Description(ctx), + fmt.Sprintf("%d", l), + )) + + return + } +} + +// LengthBetween returns an AttributeValidator which ensures that any configured +// attribute value: +// +// - Is a string. +// - Is of length greater than the given minimum and less than the given maximum. +// +// Null (unconfigured) and unknown (known after apply) values are skipped. +func LengthBetween(minLength, maxLength int) tfsdk.AttributeValidator { + if minLength < 0 || maxLength < 0 || minLength > maxLength { + return nil + } + + return lengthBetweenValidator{ + minLength: minLength, + maxLength: maxLength, + } +} diff --git a/stringvalidator/length_between_test.go b/stringvalidator/length_between_test.go new file mode 100644 index 00000000..a07e0c22 --- /dev/null +++ b/stringvalidator/length_between_test.go @@ -0,0 +1,75 @@ +package stringvalidator + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestLengthBetweenValidator(t *testing.T) { + t.Parallel() + + type testCase struct { + val attr.Value + minLength int + maxLength int + expectError bool + } + tests := map[string]testCase{ + "not a String": { + val: types.Bool{Value: true}, + expectError: true, + }, + "unknown String": { + val: types.String{Unknown: true}, + minLength: 1, + maxLength: 3, + }, + "null String": { + val: types.String{Null: true}, + minLength: 1, + maxLength: 3, + }, + "valid String": { + val: types.String{Value: "ok"}, + minLength: 1, + maxLength: 3, + }, + "too long String": { + val: types.String{Value: "not ok"}, + minLength: 1, + maxLength: 3, + expectError: true, + }, + "too short String": { + val: types.String{Value: ""}, + minLength: 1, + maxLength: 3, + expectError: true, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + request := tfsdk.ValidateAttributeRequest{ + AttributePath: tftypes.NewAttributePath().WithAttributeName("test"), + AttributeConfig: test.val, + } + response := tfsdk.ValidateAttributeResponse{} + LengthBetween(test.minLength, test.maxLength).Validate(context.TODO(), request, &response) + + if !response.Diagnostics.HasError() && test.expectError { + t.Fatal("expected error, got no error") + } + + if response.Diagnostics.HasError() && !test.expectError { + t.Fatalf("got unexpected error: %s", response.Diagnostics) + } + }) + } +} diff --git a/validatordiag/diag.go b/validatordiag/diag.go index 047cb8ca..f165cdd0 100644 --- a/validatordiag/diag.go +++ b/validatordiag/diag.go @@ -8,7 +8,7 @@ import ( "github.com/hashicorp/terraform-plugin-go/tftypes" ) -// AttributeValueDiagnostic returns an error Diagnostic to be used when an attribute has an unexpected value. +// AttributeValueDiagnostic returns an error Diagnostic to be used when an attribute has an invalid value. func AttributeValueDiagnostic(path *tftypes.AttributePath, description string, value string) diag.Diagnostic { return diag.NewAttributeErrorDiagnostic( path, @@ -17,6 +17,15 @@ func AttributeValueDiagnostic(path *tftypes.AttributePath, description string, v ) } +// AttributeValueLengthDiagnostic returns an error Diagnostic to be used when an attribute's value has an invalid length. +func AttributeValueLengthDiagnostic(path *tftypes.AttributePath, description string, value string) diag.Diagnostic { + return diag.NewAttributeErrorDiagnostic( + path, + "Invalid Attribute Value Length", + capitalize(description)+", got: "+value, + ) +} + // capitalize will uppercase the first letter in a UTF-8 string. func capitalize(str string) string { if str == "" {