diff --git a/.changelog/23.txt b/.changelog/23.txt new file mode 100644 index 00000000..f2c4b9d0 --- /dev/null +++ b/.changelog/23.txt @@ -0,0 +1,3 @@ +```release-note:feature +Introduced `stringvalidator.RegexMatches()` validation function +``` \ No newline at end of file diff --git a/stringvalidator/regex_matches.go b/stringvalidator/regex_matches.go new file mode 100644 index 00000000..1d3fbf9f --- /dev/null +++ b/stringvalidator/regex_matches.go @@ -0,0 +1,64 @@ +package stringvalidator + +import ( + "context" + "fmt" + "regexp" + + "github.com/hashicorp/terraform-plugin-framework-validators/validatordiag" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" +) + +var _ tfsdk.AttributeValidator = regexMatchesValidator{} + +// regexMatchesValidator validates that a string Attribute's value matches the specified regular expression. +type regexMatchesValidator struct { + regexp *regexp.Regexp + message string +} + +// Description describes the validation in plain text formatting. +func (validator regexMatchesValidator) Description(_ context.Context) string { + if validator.message != "" { + return validator.message + } + return fmt.Sprintf("value must match regular expression '%s'", validator.regexp) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (validator regexMatchesValidator) MarkdownDescription(ctx context.Context) string { + return validator.Description(ctx) +} + +// Validate performs the validation. +func (validator regexMatchesValidator) Validate(ctx context.Context, request tfsdk.ValidateAttributeRequest, response *tfsdk.ValidateAttributeResponse) { + s, ok := validateString(ctx, request, response) + + if !ok { + return + } + + if ok := validator.regexp.MatchString(s); !ok { + response.Diagnostics.Append(validatordiag.AttributeValueMatchesDiagnostic( + request.AttributePath, + validator.Description(ctx), + s, + )) + } +} + +// RegexMatches returns an AttributeValidator which ensures that any configured +// attribute value: +// +// - Is a string. +// - Matches the given regular expression https://github.com/google/re2/wiki/Syntax. +// +// Null (unconfigured) and unknown (known after apply) values are skipped. +// Optionally an error message can be provided to return something friendlier +// than "value must match regular expression 'regexp'". +func RegexMatches(regexp *regexp.Regexp, message string) tfsdk.AttributeValidator { + return regexMatchesValidator{ + regexp: regexp, + message: message, + } +} diff --git a/stringvalidator/regex_matches_test.go b/stringvalidator/regex_matches_test.go new file mode 100644 index 00000000..2f4ce12b --- /dev/null +++ b/stringvalidator/regex_matches_test.go @@ -0,0 +1,65 @@ +package stringvalidator + +import ( + "context" + "regexp" + "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 TestRegexMatchesValidator(t *testing.T) { + t.Parallel() + + type testCase struct { + val attr.Value + regexp *regexp.Regexp + expectError bool + } + tests := map[string]testCase{ + "not a String": { + val: types.Bool{Value: true}, + expectError: true, + }, + "unknown String": { + val: types.String{Unknown: true}, + regexp: regexp.MustCompile(`^o[j-l]?$`), + }, + "null String": { + val: types.String{Null: true}, + regexp: regexp.MustCompile(`^o[j-l]?$`), + }, + "valid String": { + val: types.String{Value: "ok"}, + regexp: regexp.MustCompile(`^o[j-l]?$`), + }, + "invalid String": { + val: types.String{Value: "not ok"}, + regexp: regexp.MustCompile(`^o[j-l]?$`), + 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{} + RegexMatches(test.regexp, "").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 f165cdd0..c43567f2 100644 --- a/validatordiag/diag.go +++ b/validatordiag/diag.go @@ -26,6 +26,15 @@ func AttributeValueLengthDiagnostic(path *tftypes.AttributePath, description str ) } +// AttributeValueMatchesDiagnostic returns an error Diagnostic to be used when an attribute's value has an invalid match. +func AttributeValueMatchesDiagnostic(path *tftypes.AttributePath, description string, value string) diag.Diagnostic { + return diag.NewAttributeErrorDiagnostic( + path, + "Invalid Attribute Value Match", + capitalize(description)+", got: "+value, + ) +} + // capitalize will uppercase the first letter in a UTF-8 string. func capitalize(str string) string { if str == "" {