diff --git a/.changelog/39.txt b/.changelog/39.txt new file mode 100644 index 00000000..a37fe99e --- /dev/null +++ b/.changelog/39.txt @@ -0,0 +1,3 @@ +```release-note:feature +Added `SizeAtLeast()`, `SizeAtMost()` and `SizeBetween` validation functions to `mapvalidator` package +``` \ No newline at end of file diff --git a/mapvalidator/size_at_least.go b/mapvalidator/size_at_least.go new file mode 100644 index 00000000..c46918a1 --- /dev/null +++ b/mapvalidator/size_at_least.go @@ -0,0 +1,58 @@ +package mapvalidator + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + + "github.com/hashicorp/terraform-plugin-framework-validators/validatordiag" +) + +var _ tfsdk.AttributeValidator = sizeAtLeastValidator{} + +// sizeAtLeastValidator validates that map contains at least min elements. +type sizeAtLeastValidator struct { + min int +} + +// Description describes the validation in plain text formatting. +func (v sizeAtLeastValidator) Description(ctx context.Context) string { + return fmt.Sprintf("map must contain at least %d elements", v.min) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (v sizeAtLeastValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +// Validate performs the validation. +func (v sizeAtLeastValidator) Validate(ctx context.Context, req tfsdk.ValidateAttributeRequest, resp *tfsdk.ValidateAttributeResponse) { + elems, ok := validateMap(ctx, req, resp) + if !ok { + return + } + + if len(elems) < v.min { + resp.Diagnostics.Append(validatordiag.AttributeValueDiagnostic( + req.AttributePath, + v.Description(ctx), + fmt.Sprintf("%d", len(elems)), + )) + + return + } +} + +// SizeAtLeast returns an AttributeValidator which ensures that any configured +// attribute value: +// +// - Is a Map. +// - Contains at least min elements. +// +// Null (unconfigured) and unknown (known after apply) values are skipped. +func SizeAtLeast(min int) tfsdk.AttributeValidator { + return sizeAtLeastValidator{ + min: min, + } +} diff --git a/mapvalidator/size_at_least_test.go b/mapvalidator/size_at_least_test.go new file mode 100644 index 00000000..6f6b16f3 --- /dev/null +++ b/mapvalidator/size_at_least_test.go @@ -0,0 +1,90 @@ +package mapvalidator + +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 TestSizeAtLeastValidator(t *testing.T) { + t.Parallel() + + type testCase struct { + val attr.Value + min int + expectError bool + } + tests := map[string]testCase{ + "not a Map": { + val: types.Bool{Value: true}, + expectError: true, + }, + "Map unknown": { + val: types.Map{ + Unknown: true, + ElemType: types.StringType, + }, + expectError: false, + }, + "Map null": { + val: types.Map{ + Null: true, + ElemType: types.StringType, + }, + expectError: false, + }, + "Map size greater than min": { + val: types.Map{ + ElemType: types.StringType, + Elems: map[string]attr.Value{ + "one": types.String{Value: "first"}, + "two": types.String{Value: "second"}, + }, + }, + min: 1, + expectError: false, + }, + "Map size equal to min": { + val: types.Map{ + ElemType: types.StringType, + Elems: map[string]attr.Value{ + "one": types.String{Value: "first"}, + }, + }, + min: 1, + expectError: false, + }, + "Map size less than min": { + val: types.Map{ + ElemType: types.StringType, + Elems: map[string]attr.Value{}, + }, + min: 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{} + SizeAtLeast(test.min).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/mapvalidator/size_at_most.go b/mapvalidator/size_at_most.go new file mode 100644 index 00000000..994a8c86 --- /dev/null +++ b/mapvalidator/size_at_most.go @@ -0,0 +1,58 @@ +package mapvalidator + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + + "github.com/hashicorp/terraform-plugin-framework-validators/validatordiag" +) + +var _ tfsdk.AttributeValidator = sizeAtMostValidator{} + +// sizeAtMostValidator validates that map contains at most max elements. +type sizeAtMostValidator struct { + max int +} + +// Description describes the validation in plain text formatting. +func (v sizeAtMostValidator) Description(ctx context.Context) string { + return fmt.Sprintf("map must contain at most %d elements", v.max) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (v sizeAtMostValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +// Validate performs the validation. +func (v sizeAtMostValidator) Validate(ctx context.Context, req tfsdk.ValidateAttributeRequest, resp *tfsdk.ValidateAttributeResponse) { + elems, ok := validateMap(ctx, req, resp) + if !ok { + return + } + + if len(elems) > v.max { + resp.Diagnostics.Append(validatordiag.AttributeValueDiagnostic( + req.AttributePath, + v.Description(ctx), + fmt.Sprintf("%d", len(elems)), + )) + + return + } +} + +// SizeAtMost returns an AttributeValidator which ensures that any configured +// attribute value: +// +// - Is a Map. +// - Contains at most max elements. +// +// Null (unconfigured) and unknown (known after apply) values are skipped. +func SizeAtMost(max int) tfsdk.AttributeValidator { + return sizeAtMostValidator{ + max: max, + } +} diff --git a/mapvalidator/size_at_most_test.go b/mapvalidator/size_at_most_test.go new file mode 100644 index 00000000..231948a6 --- /dev/null +++ b/mapvalidator/size_at_most_test.go @@ -0,0 +1,93 @@ +package mapvalidator + +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 TestSizeAtMostValidator(t *testing.T) { + t.Parallel() + + type testCase struct { + val attr.Value + max int + expectError bool + } + tests := map[string]testCase{ + "not a Map": { + val: types.Bool{Value: true}, + expectError: true, + }, + "Map unknown": { + val: types.Map{ + Unknown: true, + ElemType: types.StringType, + }, + expectError: false, + }, + "Map null": { + val: types.Map{ + Null: true, + ElemType: types.StringType, + }, + expectError: false, + }, + "Map size less than max": { + val: types.Map{ + ElemType: types.StringType, + Elems: map[string]attr.Value{ + "one": types.String{Value: "first"}, + }, + }, + max: 2, + expectError: false, + }, + "Map size equal to max": { + val: types.Map{ + ElemType: types.StringType, + Elems: map[string]attr.Value{ + "one": types.String{Value: "first"}, + "two": types.String{Value: "second"}, + }, + }, + max: 2, + expectError: false, + }, + "Map size greater than max": { + val: types.Map{ + ElemType: types.StringType, + Elems: map[string]attr.Value{ + "one": types.String{Value: "first"}, + "two": types.String{Value: "second"}, + "three": types.String{Value: "third"}, + }}, + max: 2, + 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{} + SizeAtMost(test.max).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/mapvalidator/size_between.go b/mapvalidator/size_between.go new file mode 100644 index 00000000..96be810f --- /dev/null +++ b/mapvalidator/size_between.go @@ -0,0 +1,61 @@ +package mapvalidator + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + + "github.com/hashicorp/terraform-plugin-framework-validators/validatordiag" +) + +var _ tfsdk.AttributeValidator = sizeBetweenValidator{} + +// sizeBetweenValidator validates that map contains at least min elements +// and at most max elements. +type sizeBetweenValidator struct { + min int + max int +} + +// Description describes the validation in plain text formatting. +func (v sizeBetweenValidator) Description(ctx context.Context) string { + return fmt.Sprintf("map must contain at least %d elements and at most %d elements", v.min, v.max) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (v sizeBetweenValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +// Validate performs the validation. +func (v sizeBetweenValidator) Validate(ctx context.Context, req tfsdk.ValidateAttributeRequest, resp *tfsdk.ValidateAttributeResponse) { + elems, ok := validateMap(ctx, req, resp) + if !ok { + return + } + + if len(elems) < v.min || len(elems) > v.max { + resp.Diagnostics.Append(validatordiag.AttributeValueDiagnostic( + req.AttributePath, + v.Description(ctx), + fmt.Sprintf("%d", len(elems)), + )) + + return + } +} + +// SizeBetween returns an AttributeValidator which ensures that any configured +// attribute value: +// +// - Is a Map. +// - Contains at least min elements and at most max elements. +// +// Null (unconfigured) and unknown (known after apply) values are skipped. +func SizeBetween(min, max int) tfsdk.AttributeValidator { + return sizeBetweenValidator{ + min: min, + max: max, + } +} diff --git a/mapvalidator/size_between_test.go b/mapvalidator/size_between_test.go new file mode 100644 index 00000000..75c5db22 --- /dev/null +++ b/mapvalidator/size_between_test.go @@ -0,0 +1,133 @@ +package mapvalidator + +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 TestSizeBetweenValidator(t *testing.T) { + t.Parallel() + + type testCase struct { + val attr.Value + min int + max int + expectError bool + } + tests := map[string]testCase{ + "not a Map": { + val: types.Bool{Value: true}, + expectError: true, + }, + "Map unknown": { + val: types.Map{ + Unknown: true, + ElemType: types.StringType, + }, + expectError: false, + }, + "Map null": { + val: types.Map{ + Null: true, + ElemType: types.StringType, + }, + expectError: false, + }, + "Map size greater than min": { + val: types.Map{ + ElemType: types.StringType, + Elems: map[string]attr.Value{ + "one": types.String{Value: "first"}, + "two": types.String{Value: "second"}, + }, + }, + min: 1, + max: 3, + expectError: false, + }, + "Map size equal to min": { + val: types.Map{ + ElemType: types.StringType, + Elems: map[string]attr.Value{ + "one": types.String{Value: "first"}, + }, + }, + min: 1, + max: 3, + expectError: false, + }, + "Map size less than max": { + val: types.Map{ + ElemType: types.StringType, + Elems: map[string]attr.Value{ + "one": types.String{Value: "first"}, + "two": types.String{Value: "second"}, + }, + }, + min: 1, + max: 3, + expectError: false, + }, + "Map size equal to max": { + val: types.Map{ + ElemType: types.StringType, + Elems: map[string]attr.Value{ + "one": types.String{Value: "first"}, + "two": types.String{Value: "second"}, + "three": types.String{Value: "third"}, + }, + }, + min: 1, + max: 3, + expectError: false, + }, + "Map size less than min": { + val: types.Map{ + ElemType: types.StringType, + Elems: map[string]attr.Value{}, + }, + min: 1, + max: 3, + expectError: true, + }, + "Map size greater than max": { + val: types.Map{ + ElemType: types.StringType, + Elems: map[string]attr.Value{ + "one": types.String{Value: "first"}, + "two": types.String{Value: "second"}, + "three": types.String{Value: "third"}, + "four": types.String{Value: "fourth"}, + }, + }, + min: 1, + max: 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{} + SizeBetween(test.min, test.max).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) + } + }) + } +}