Skip to content
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

Add various Map key and value validators #304

Merged
merged 6 commits into from
Apr 22, 2020
Merged
Show file tree
Hide file tree
Changes from 5 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
144 changes: 144 additions & 0 deletions helper/validation/map.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package validation

import (
"fmt"
"regexp"
"sort"

"github.com/hashicorp/go-cty/cty"
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)

// MapKeyLenBetween returns a SchemaValidateDiagFunc which tests if the provided value
// is of type map and the length of all keys are between min and max (inclusive)
func MapKeyLenBetween(min, max int) schema.SchemaValidateDiagFunc {
return func(v interface{}, path cty.Path) diag.Diagnostics {
var diags diag.Diagnostics

for _, key := range sortedKeys(v.(map[string]interface{})) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could sort here or just sort in the check/test instead. Having a deterministic user experience for the diagnostics might be nice though so sorting here is certainly appropriate.

len := len(key)
if len < min || len > max {
diags = append(diags, diag.Diagnostic{
Severity: diag.Error,
Copy link
Contributor

@appilon appilon Apr 21, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned in my question answering it might be a better pattern to declare var diags diag.Diagnostics at the top and append to the slice, returning diags at the end. You could exit at the first error or actually accumulate all of them to return. Of course in situations where you cannot continue you should exit early, but in situations such as this were we can actually successfully collect multiple errors you have the ability to do so.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now I'm exiting at the first error but it would make sense to accumulate a Diagnostic for every key (in this function) that is in error.
This will complicate the Equals helper or expanded equivalent as for example map iteration is non-deterministic and the Diagnostics entries will potentially be in different orders.
OK, I think I've just talked myself into sorting the keys and then iterating the map in a known order and accumulating all errors - The user's experience will be better in this case.

Summary: "Bad map key length",
Detail: fmt.Sprintf("Map key lengths should be in the range (%d - %d): %s (length = %d)", min, max, key, len),
AttributePath: append(path, cty.IndexStep{Key: cty.StringVal(key)}),
})
}
}

return diags
}
}

// MapValueLenBetween returns a SchemaValidateDiagFunc which tests if the provided value
// is of type map and the length of all values are between min and max (inclusive)
func MapValueLenBetween(min, max int) schema.SchemaValidateDiagFunc {
return func(v interface{}, path cty.Path) diag.Diagnostics {
var diags diag.Diagnostics

m := v.(map[string]interface{})

for _, key := range sortedKeys(m) {
val := m[key]

if _, ok := val.(string); !ok {
diags = append(diags, diag.Diagnostic{
Severity: diag.Error,
Summary: "Bad map value type",
Detail: fmt.Sprintf("Map values should be strings: %s => %v (type = %T)", key, val, val),
AttributePath: append(path, cty.IndexStep{Key: cty.StringVal(key)}),
})
continue
}

len := len(val.(string))
if len < min || len > max {
diags = append(diags, diag.Diagnostic{
Severity: diag.Error,
Summary: "Bad map value length",
Detail: fmt.Sprintf("Map value lengths should be in the range (%d - %d): %s => %v (length = %d)", min, max, key, val, len),
AttributePath: append(path, cty.IndexStep{Key: cty.StringVal(key)}),
})
}
}

return diags
}
}

// MapKeyMatch returns a SchemaValidateFunc which tests if the provided value
appilon marked this conversation as resolved.
Show resolved Hide resolved
// is of type map and all keys match a given regexp. Optionally an error message
// can be provided to return something friendlier than "expected to match some globby regexp".
func MapKeyMatch(r *regexp.Regexp, message string) schema.SchemaValidateFunc {
return func(i interface{}, k string) (warnings []string, errors []error) {
v, ok := i.(map[string]interface{})
if !ok {
errors = append(errors, fmt.Errorf("expected type of %[1]q to be Map, got %[1]T", k))
return warnings, errors
}

for key := range v {
if ok := r.MatchString(key); !ok {
if message != "" {
errors = append(errors, fmt.Errorf("invalid key %q for %q (%s)", key, k, message))
return warnings, errors
}

errors = append(errors, fmt.Errorf("invalid key %q for %q (expected to match regular expression %q)", key, k, r))
return warnings, errors
}
}

return warnings, errors
}
}

// MapValueMatch returns a SchemaValidateFunc which tests if the provided value
// is of type map and all values match a given regexp. Optionally an error message
// can be provided to return something friendlier than "expected to match some globby regexp".
func MapValueMatch(r *regexp.Regexp, message string) schema.SchemaValidateFunc {
return func(i interface{}, k string) (warnings []string, errors []error) {
v, ok := i.(map[string]interface{})
if !ok {
errors = append(errors, fmt.Errorf("expected type of %[1]q to be Map, got %[1]T", k))
return warnings, errors
}

for _, val := range v {
if _, ok := val.(string); !ok {
errors = append(errors, fmt.Errorf("expected all values of %[1]q to be strings, found %[2]v (type = %[2]T)", k, val))
return warnings, errors
}
}

for _, val := range v {
if ok := r.MatchString(val.(string)); !ok {
if message != "" {
errors = append(errors, fmt.Errorf("invalid value %q for %q (%s)", val, k, message))
return warnings, errors
}

errors = append(errors, fmt.Errorf("invalid value %q for %q (expected to match regular expression %q)", val, k, r))
return warnings, errors
}
}

return warnings, errors
}
}

func sortedKeys(m map[string]interface{}) []string {
keys := make([]string, len(m))

i := 0
for key := range m {
keys[i] = key
i++
}

sort.Strings(keys)

return keys
}
254 changes: 254 additions & 0 deletions helper/validation/map_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
package validation

import (
"regexp"
"testing"

"github.com/hashicorp/go-cty/cty"
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
)

func TestValidationMapKeyLenBetween(t *testing.T) {
cases := map[string]struct {
Value interface{}
ExpectedDiags diag.Diagnostics
}{
"TooLong": {
Value: map[string]interface{}{
"ABC": "123",
"UVWXYZ": "123456",
},
ExpectedDiags: diag.Diagnostics{
{
Severity: diag.Error,
AttributePath: append(cty.Path{}, cty.IndexStep{Key: cty.StringVal("UVWXYZ")}),
},
},
},
"TooShort": {
Value: map[string]interface{}{
"ABC": "123",
"U": "1",
},
ExpectedDiags: diag.Diagnostics{
{
Severity: diag.Error,
AttributePath: append(cty.Path{}, cty.IndexStep{Key: cty.StringVal("U")}),
},
},
},
"TooLongAndTooShort": {
Value: map[string]interface{}{
"UVWXYZ": "123456",
"ABC": "123",
"U": "1",
},
ExpectedDiags: diag.Diagnostics{
{
Severity: diag.Error,
AttributePath: append(cty.Path{}, cty.IndexStep{Key: cty.StringVal("U")}),
},
{
Severity: diag.Error,
AttributePath: append(cty.Path{}, cty.IndexStep{Key: cty.StringVal("UVWXYZ")}),
},
},
},
"AllGood": {
Value: map[string]interface{}{
"AB": "12",
"UVWXY": "12345",
},
ExpectedDiags: nil,
},
}

fn := MapKeyLenBetween(2, 5)

for tn, tc := range cases {
t.Run(tn, func(t *testing.T) {
diags := fn(tc.Value, cty.Path{})

checkDiagnostics(t, tn, diags, tc.ExpectedDiags)
})
}
}

func TestValidationMapValueLenBetween(t *testing.T) {
cases := map[string]struct {
Value interface{}
ExpectedDiags diag.Diagnostics
}{
"NotStringValue": {
Value: map[string]interface{}{
"ABC": "123",
"UVWXYZ": 123456,
},
ExpectedDiags: diag.Diagnostics{
{
Severity: diag.Error,
AttributePath: append(cty.Path{}, cty.IndexStep{Key: cty.StringVal("UVWXYZ")}),
},
},
},
"TooLong": {
Value: map[string]interface{}{
"ABC": "123",
"UVWXYZ": "123456",
},
ExpectedDiags: diag.Diagnostics{
{
Severity: diag.Error,
AttributePath: append(cty.Path{}, cty.IndexStep{Key: cty.StringVal("UVWXYZ")}),
},
},
},
"TooShort": {
Value: map[string]interface{}{
"ABC": "123",
"U": "1",
},
ExpectedDiags: diag.Diagnostics{
{
Severity: diag.Error,
AttributePath: append(cty.Path{}, cty.IndexStep{Key: cty.StringVal("U")}),
},
},
},
"TooLongAndTooShort": {
Value: map[string]interface{}{
"UVWXYZ": "123456",
"ABC": "123",
"U": "1",
},
ExpectedDiags: diag.Diagnostics{
{
Severity: diag.Error,
AttributePath: append(cty.Path{}, cty.IndexStep{Key: cty.StringVal("U")}),
},
{
Severity: diag.Error,
AttributePath: append(cty.Path{}, cty.IndexStep{Key: cty.StringVal("UVWXYZ")}),
},
},
},
"AllGood": {
Value: map[string]interface{}{
"AB": "12",
"UVWXY": "12345",
},
ExpectedDiags: nil,
},
}

fn := MapValueLenBetween(2, 5)

for tn, tc := range cases {
t.Run(tn, func(t *testing.T) {
diags := fn(tc.Value, cty.Path{})

checkDiagnostics(t, tn, diags, tc.ExpectedDiags)
})
}
}

func TestValidationMapKeyMatch(t *testing.T) {
cases := map[string]struct {
Value interface{}
Error bool
}{
"NotMap": {
Value: "the map is a lie",
Error: true,
},
"NoMatch": {
Value: map[string]interface{}{
"ABC": "123",
"UVWXYZ": "123456",
},
Error: true,
},
"AllGood": {
Value: map[string]interface{}{
"AB": "12",
"UVABY": "12345",
},
Error: false,
},
}

fn := MapKeyMatch(regexp.MustCompile(".*AB.*"), "")

for tn, tc := range cases {
t.Run(tn, func(t *testing.T) {
_, errors := fn(tc.Value, tn)

if len(errors) > 0 && !tc.Error {
t.Errorf("MapKeyMatch(%s) produced an unexpected error", tc.Value)
} else if len(errors) == 0 && tc.Error {
t.Errorf("MapKeyMatch(%s) did not error", tc.Value)
}
})
}
}

func TestValidationValueKeyMatch(t *testing.T) {
cases := map[string]struct {
Value interface{}
Error bool
}{
"NotMap": {
Value: "the map is a lie",
Error: true,
},
"NotStringValue": {
Value: map[string]interface{}{
"MNO": "123",
"UVWXYZ": 123456,
},
Error: true,
},
"NoMatch": {
Value: map[string]interface{}{
"MNO": "ABC",
"UVWXYZ": "UVWXYZ",
},
Error: true,
},
"AllGood": {
Value: map[string]interface{}{
"MNO": "ABC",
"UVWXYZ": "UVABY",
},
Error: false,
},
}

fn := MapValueMatch(regexp.MustCompile(".*AB.*"), "")

for tn, tc := range cases {
t.Run(tn, func(t *testing.T) {
_, errors := fn(tc.Value, tn)

if len(errors) > 0 && !tc.Error {
t.Errorf("MapValueMatch(%s) produced an unexpected error", tc.Value)
} else if len(errors) == 0 && tc.Error {
t.Errorf("MapValueMatch(%s) did not error", tc.Value)
}
})
}
}

func checkDiagnostics(t *testing.T, tn string, got, expected diag.Diagnostics) {
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 added this for now. As you mention, maybe replace with a method on Diagnostics?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is good enough for now

if len(got) != len(expected) {
t.Fatalf("%s: wrong number of diags, expected %d, got %d", tn, len(expected), len(got))
}
for j := range got {
if got[j].Severity != expected[j].Severity {
t.Fatalf("%s: expected severity %v, got %v", tn, expected[j].Severity, got[j].Severity)
}
if !got[j].AttributePath.Equals(expected[j].AttributePath) {
t.Fatalf("%s: attribute paths do not match expected: %v, got %v", tn, expected[j].AttributePath, got[j].AttributePath)
}
}
}