-
Notifications
You must be signed in to change notification settings - Fork 13
Adding Map validation for KeysAre and ValuesAre #38
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
bcdec31
7970d3e
e5550b3
5001b0d
8815771
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
```release-note:feature | ||
Introduced `mapvalidator` package with `ValuesAre()` validation function | ||
``` |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
// Package mapvalidator provides validators for types.Map attributes. | ||
package mapvalidator |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,64 @@ | ||
package mapvalidator | ||
|
||
import ( | ||
"context" | ||
"fmt" | ||
"strings" | ||
|
||
"github.com/hashicorp/terraform-plugin-framework/tfsdk" | ||
"github.com/hashicorp/terraform-plugin-framework/types" | ||
) | ||
|
||
var _ tfsdk.AttributeValidator = keysAreValidator{} | ||
|
||
// keysAreValidator validates that each map key validates against each of the value validators. | ||
type keysAreValidator struct { | ||
keyValidators []tfsdk.AttributeValidator | ||
} | ||
|
||
// Description describes the validation in plain text formatting. | ||
func (v keysAreValidator) Description(ctx context.Context) string { | ||
var descriptions []string | ||
for _, validator := range v.keyValidators { | ||
descriptions = append(descriptions, validator.Description(ctx)) | ||
} | ||
|
||
return fmt.Sprintf("key must satisfy all validations: %s", strings.Join(descriptions, " + ")) | ||
} | ||
|
||
// MarkdownDescription describes the validation in Markdown formatting. | ||
func (v keysAreValidator) MarkdownDescription(ctx context.Context) string { | ||
return v.Description(ctx) | ||
} | ||
|
||
// Validate performs the validation. | ||
// Note that the AttributePath specified in the ValidateAttributeRequest refers to the value in the Map with key `k`, | ||
// whereas the AttributeConfig refers to the key itself (i.e., `k`). This is intentional as the validation being | ||
// performed is for the keys of the Map. | ||
func (v keysAreValidator) Validate(ctx context.Context, req tfsdk.ValidateAttributeRequest, resp *tfsdk.ValidateAttributeResponse) { | ||
elems, ok := validateMap(ctx, req, resp) | ||
if !ok { | ||
return | ||
} | ||
|
||
for k := range elems { | ||
request := tfsdk.ValidateAttributeRequest{ | ||
AttributePath: req.AttributePath.WithElementKeyString(k), | ||
AttributeConfig: types.String{Value: k}, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This seems a bit "hacky" to me. If a validator that is subsequently called by ranging over There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this is something to call in the Go documentation for the validator/function, but expected in this usage. The intention of the validator is to loop through the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've updated the docs to provide some clarification around this usage. |
||
Config: req.Config, | ||
} | ||
|
||
for _, validator := range v.keyValidators { | ||
validator.Validate(ctx, request, resp) | ||
if resp.Diagnostics.HasError() { | ||
return | ||
} | ||
} | ||
} | ||
} | ||
|
||
func KeysAre(keyValidators ...tfsdk.AttributeValidator) tfsdk.AttributeValidator { | ||
return keysAreValidator{ | ||
keyValidators: keyValidators, | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,119 @@ | ||
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" | ||
|
||
"github.com/hashicorp/terraform-plugin-framework-validators/int64validator" | ||
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" | ||
) | ||
|
||
func TestKeysAreValidator(t *testing.T) { | ||
t.Parallel() | ||
|
||
type testCase struct { | ||
val attr.Value | ||
keysAreValidators []tfsdk.AttributeValidator | ||
expectError bool | ||
} | ||
tests := map[string]testCase{ | ||
"not Map": { | ||
val: types.List{ | ||
ElemType: types.StringType, | ||
}, | ||
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 key invalid": { | ||
val: types.Map{ | ||
ElemType: types.StringType, | ||
Elems: map[string]attr.Value{ | ||
"one": types.String{Value: "first"}, | ||
"two": types.String{Value: "second"}, | ||
}, | ||
}, | ||
keysAreValidators: []tfsdk.AttributeValidator{ | ||
stringvalidator.LengthAtLeast(4), | ||
}, | ||
expectError: true, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: At some point it might be nice to refactor these validator tests to test the expected error messages, rather than just being conditional that some error was raised. |
||
}, | ||
"Map key invalid for second validator": { | ||
val: types.Map{ | ||
ElemType: types.StringType, | ||
Elems: map[string]attr.Value{ | ||
"one": types.String{Value: "first"}, | ||
"two": types.String{Value: "second"}, | ||
}, | ||
}, | ||
keysAreValidators: []tfsdk.AttributeValidator{ | ||
stringvalidator.LengthAtLeast(2), | ||
stringvalidator.LengthAtLeast(6), | ||
}, | ||
expectError: true, | ||
}, | ||
"Map keys wrong type for validator": { | ||
val: types.Map{ | ||
ElemType: types.StringType, | ||
Elems: map[string]attr.Value{ | ||
"one": types.String{Value: "first"}, | ||
"two": types.String{Value: "second"}, | ||
}, | ||
}, | ||
keysAreValidators: []tfsdk.AttributeValidator{ | ||
int64validator.AtLeast(6), | ||
}, | ||
expectError: true, | ||
}, | ||
"Map keys valid": { | ||
val: types.Map{ | ||
ElemType: types.StringType, | ||
Elems: map[string]attr.Value{ | ||
"one": types.String{Value: "first"}, | ||
"two": types.String{Value: "second"}, | ||
}, | ||
}, | ||
keysAreValidators: []tfsdk.AttributeValidator{ | ||
stringvalidator.LengthAtLeast(3), | ||
}, | ||
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{} | ||
KeysAre(test.keysAreValidators...).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) | ||
} | ||
}) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
package mapvalidator | ||
|
||
import ( | ||
"context" | ||
|
||
"github.com/hashicorp/terraform-plugin-framework/attr" | ||
"github.com/hashicorp/terraform-plugin-framework/tfsdk" | ||
"github.com/hashicorp/terraform-plugin-framework/types" | ||
) | ||
|
||
// validateMap ensures that the request contains a Map value. | ||
func validateMap(ctx context.Context, request tfsdk.ValidateAttributeRequest, response *tfsdk.ValidateAttributeResponse) (map[string]attr.Value, bool) { | ||
var m types.Map | ||
|
||
diags := tfsdk.ValueAs(ctx, request.AttributeConfig, &m) | ||
|
||
if diags.HasError() { | ||
response.Diagnostics = append(response.Diagnostics, diags...) | ||
|
||
return nil, false | ||
} | ||
|
||
if m.Unknown || m.Null { | ||
return nil, false | ||
} | ||
|
||
return m.Elems, true | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,82 @@ | ||
package mapvalidator | ||
|
||
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 TestValidateMap(t *testing.T) { | ||
t.Parallel() | ||
|
||
testCases := map[string]struct { | ||
request tfsdk.ValidateAttributeRequest | ||
expectedMap map[string]attr.Value | ||
expectedOk bool | ||
}{ | ||
"invalid-type": { | ||
request: tfsdk.ValidateAttributeRequest{ | ||
AttributeConfig: types.Bool{Value: true}, | ||
AttributePath: tftypes.NewAttributePath().WithAttributeName("test"), | ||
}, | ||
expectedMap: nil, | ||
expectedOk: false, | ||
}, | ||
"map-null": { | ||
request: tfsdk.ValidateAttributeRequest{ | ||
AttributeConfig: types.Map{Null: true}, | ||
AttributePath: tftypes.NewAttributePath().WithAttributeName("test"), | ||
}, | ||
expectedMap: nil, | ||
expectedOk: false, | ||
}, | ||
"map-unknown": { | ||
request: tfsdk.ValidateAttributeRequest{ | ||
AttributeConfig: types.Map{Unknown: true}, | ||
AttributePath: tftypes.NewAttributePath().WithAttributeName("test"), | ||
}, | ||
expectedMap: nil, | ||
expectedOk: false, | ||
}, | ||
"map-value": { | ||
request: tfsdk.ValidateAttributeRequest{ | ||
AttributeConfig: types.Map{ | ||
ElemType: types.StringType, | ||
Elems: map[string]attr.Value{ | ||
"one": types.String{Value: "first"}, | ||
"two": types.String{Value: "second"}, | ||
}, | ||
}, | ||
AttributePath: tftypes.NewAttributePath().WithAttributeName("test"), | ||
}, | ||
expectedMap: map[string]attr.Value{ | ||
"one": types.String{Value: "first"}, | ||
"two": types.String{Value: "second"}, | ||
}, | ||
expectedOk: true, | ||
}, | ||
} | ||
|
||
for name, testCase := range testCases { | ||
name, testCase := name, testCase | ||
|
||
t.Run(name, func(t *testing.T) { | ||
t.Parallel() | ||
|
||
gotMapElems, gotOk := validateMap(context.Background(), testCase.request, &tfsdk.ValidateAttributeResponse{}) | ||
|
||
if diff := cmp.Diff(gotMapElems, testCase.expectedMap); diff != "" { | ||
t.Errorf("unexpected map difference: %s", diff) | ||
} | ||
|
||
if diff := cmp.Diff(gotOk, testCase.expectedOk); diff != "" { | ||
t.Errorf("unexpected ok difference: %s", diff) | ||
} | ||
}) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,64 @@ | ||
package mapvalidator | ||
|
||
import ( | ||
"context" | ||
"fmt" | ||
"strings" | ||
|
||
"github.com/hashicorp/terraform-plugin-framework/tfsdk" | ||
) | ||
|
||
var _ tfsdk.AttributeValidator = valuesAreValidator{} | ||
|
||
// valuesAreValidator validates that each map value 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 := validateMap(ctx, req, resp) | ||
if !ok { | ||
return | ||
} | ||
|
||
for k, elem := range elems { | ||
request := tfsdk.ValidateAttributeRequest{ | ||
AttributePath: req.AttributePath.WithElementKeyString(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 Map. | ||
// - Contains Map 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, | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Excellent choice here to highlight the whole key/value (at least I think it'll highlight the key too, but regardless, we do not have a way to signal upstream just a map key so this is a good compromise rather than just highlighting the whole map).