Skip to content

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

Merged
merged 5 commits into from
Jun 23, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changelog/38.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:feature
Introduced `mapvalidator` package with `ValuesAre()` validation function
```
2 changes: 2 additions & 0 deletions mapvalidator/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Package mapvalidator provides validators for types.Map attributes.
package mapvalidator
64 changes: 64 additions & 0 deletions mapvalidator/keys_are.go
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),
Copy link
Contributor

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).

AttributeConfig: types.String{Value: k},
Copy link
Contributor Author

@bendbennett bendbennett Jun 21, 2022

Choose a reason for hiding this comment

The 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 v.keyValidators uses the AttributePath to obtain the value for performing the validation this will break. Interested to hear opinions.

Copy link
Contributor

Choose a reason for hiding this comment

The 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 string map keys to perform string validations against those keys and the ValuesAre validator is separate for this reason.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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,
}
}
119 changes: 119 additions & 0 deletions mapvalidator/keys_are_test.go
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,
Copy link
Contributor

Choose a reason for hiding this comment

The 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)
}
})
}
}
28 changes: 28 additions & 0 deletions mapvalidator/type_validation.go
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
}
82 changes: 82 additions & 0 deletions mapvalidator/type_validation_test.go
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)
}
})
}
}
64 changes: 64 additions & 0 deletions mapvalidator/values_are.go
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,
}
}
Loading