diff --git a/.changelog/37.txt b/.changelog/37.txt new file mode 100644 index 0000000..45554f5 --- /dev/null +++ b/.changelog/37.txt @@ -0,0 +1,3 @@ +```release-note:feature +Introduced `listvalidator` package with `ValuesAre()` validation functions +``` \ No newline at end of file diff --git a/listvalidator/doc.go b/listvalidator/doc.go new file mode 100644 index 0000000..4847698 --- /dev/null +++ b/listvalidator/doc.go @@ -0,0 +1,2 @@ +// Package listvalidator provides validators for types.List attributes. +package listvalidator diff --git a/listvalidator/type_validation.go b/listvalidator/type_validation.go new file mode 100644 index 0000000..4ebeae2 --- /dev/null +++ b/listvalidator/type_validation.go @@ -0,0 +1,28 @@ +package listvalidator + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// validateList ensures that the request contains a List value. +func validateList(ctx context.Context, request tfsdk.ValidateAttributeRequest, response *tfsdk.ValidateAttributeResponse) ([]attr.Value, bool) { + var l types.List + + diags := tfsdk.ValueAs(ctx, request.AttributeConfig, &l) + + if diags.HasError() { + response.Diagnostics = append(response.Diagnostics, diags...) + + return nil, false + } + + if l.Unknown || l.Null { + return nil, false + } + + return l.Elems, true +} diff --git a/listvalidator/type_validation_test.go b/listvalidator/type_validation_test.go new file mode 100644 index 0000000..fe082dd --- /dev/null +++ b/listvalidator/type_validation_test.go @@ -0,0 +1,82 @@ +package listvalidator + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "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 TestValidateList(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request tfsdk.ValidateAttributeRequest + expectedListElems []attr.Value + expectedOk bool + }{ + "invalid-type": { + request: tfsdk.ValidateAttributeRequest{ + AttributeConfig: types.Bool{Value: true}, + AttributePath: tftypes.NewAttributePath().WithAttributeName("test"), + }, + expectedListElems: nil, + expectedOk: false, + }, + "list-null": { + request: tfsdk.ValidateAttributeRequest{ + AttributeConfig: types.List{Null: true}, + AttributePath: tftypes.NewAttributePath().WithAttributeName("test"), + }, + expectedListElems: nil, + expectedOk: false, + }, + "list-unknown": { + request: tfsdk.ValidateAttributeRequest{ + AttributeConfig: types.List{Unknown: true}, + AttributePath: tftypes.NewAttributePath().WithAttributeName("test"), + }, + expectedListElems: nil, + expectedOk: false, + }, + "list-value": { + request: tfsdk.ValidateAttributeRequest{ + AttributeConfig: types.List{ + ElemType: types.StringType, + Elems: []attr.Value{ + types.String{Value: "first"}, + types.String{Value: "second"}, + }, + }, + AttributePath: tftypes.NewAttributePath().WithAttributeName("test"), + }, + expectedListElems: []attr.Value{ + types.String{Value: "first"}, + types.String{Value: "second"}, + }, + expectedOk: true, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + gotListElems, gotOk := validateList(context.Background(), testCase.request, &tfsdk.ValidateAttributeResponse{}) + + if diff := cmp.Diff(gotListElems, testCase.expectedListElems); diff != "" { + t.Errorf("unexpected float64 difference: %s", diff) + } + + if diff := cmp.Diff(gotOk, testCase.expectedOk); diff != "" { + t.Errorf("unexpected ok difference: %s", diff) + } + }) + } +} diff --git a/listvalidator/values_are.go b/listvalidator/values_are.go new file mode 100644 index 0000000..ac8e06b --- /dev/null +++ b/listvalidator/values_are.go @@ -0,0 +1,64 @@ +package listvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/tfsdk" +) + +var _ tfsdk.AttributeValidator = valuesAreValidator{} + +// valuesAreValidator validates that each list member validates against each of the value validators. +type valuesAreValidator struct { + valueValidators []tfsdk.AttributeValidator +} + +// Description describes the validation in plain text formatting. +func (v valuesAreValidator) Description(ctx context.Context) string { + var descriptions []string + for _, validator := range v.valueValidators { + descriptions = append(descriptions, validator.Description(ctx)) + } + + return fmt.Sprintf("value must satisfy all validations: %s", strings.Join(descriptions, " + ")) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (v valuesAreValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +// Validate performs the validation. +func (v valuesAreValidator) Validate(ctx context.Context, req tfsdk.ValidateAttributeRequest, resp *tfsdk.ValidateAttributeResponse) { + elems, ok := validateList(ctx, req, resp) + if !ok { + return + } + + for k, elem := range elems { + request := tfsdk.ValidateAttributeRequest{ + AttributePath: req.AttributePath.WithElementKeyInt(k), + AttributeConfig: elem, + Config: req.Config, + } + + for _, validator := range v.valueValidators { + validator.Validate(ctx, request, resp) + } + } +} + +// ValuesAre returns an AttributeValidator which ensures that any configured +// attribute value: +// +// - Is a List. +// - That contains list elements, each of which validate against each value validator. +// +// Null (unconfigured) and unknown (known after apply) values are skipped. +func ValuesAre(valueValidators ...tfsdk.AttributeValidator) tfsdk.AttributeValidator { + return valuesAreValidator{ + valueValidators: valueValidators, + } +} diff --git a/listvalidator/values_are_test.go b/listvalidator/values_are_test.go new file mode 100644 index 0000000..b5732fa --- /dev/null +++ b/listvalidator/values_are_test.go @@ -0,0 +1,119 @@ +package listvalidator + +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" + + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" +) + +func TestValuesAreValidator(t *testing.T) { + t.Parallel() + + type testCase struct { + val attr.Value + valuesAreValidators []tfsdk.AttributeValidator + expectError bool + } + tests := map[string]testCase{ + "not List": { + val: types.Set{ + ElemType: types.StringType, + }, + expectError: true, + }, + "List unknown": { + val: types.List{ + Unknown: true, + ElemType: types.StringType, + }, + expectError: false, + }, + "List null": { + val: types.List{ + Null: true, + ElemType: types.StringType, + }, + expectError: false, + }, + "List elems invalid": { + val: types.List{ + ElemType: types.StringType, + Elems: []attr.Value{ + types.String{Value: "first"}, + types.String{Value: "second"}, + }, + }, + valuesAreValidators: []tfsdk.AttributeValidator{ + stringvalidator.LengthAtLeast(6), + }, + expectError: true, + }, + "List elems invalid for second validator": { + val: types.List{ + ElemType: types.StringType, + Elems: []attr.Value{ + types.String{Value: "first"}, + types.String{Value: "second"}, + }, + }, + valuesAreValidators: []tfsdk.AttributeValidator{ + stringvalidator.LengthAtLeast(2), + stringvalidator.LengthAtLeast(6), + }, + expectError: true, + }, + "List elems wrong type for validator": { + val: types.List{ + ElemType: types.StringType, + Elems: []attr.Value{ + types.String{Value: "first"}, + types.String{Value: "second"}, + }, + }, + valuesAreValidators: []tfsdk.AttributeValidator{ + int64validator.AtLeast(6), + }, + expectError: true, + }, + "List elems valid": { + val: types.List{ + ElemType: types.StringType, + Elems: []attr.Value{ + types.String{Value: "first"}, + types.String{Value: "second"}, + }, + }, + valuesAreValidators: []tfsdk.AttributeValidator{ + stringvalidator.LengthAtLeast(5), + }, + expectError: false, + }, + } + + 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{} + ValuesAre(test.valuesAreValidators...).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) + } + }) + } +}