Skip to content

Commit

Permalink
Merge pull request #358 from Juniper/set-unique-validator
Browse files Browse the repository at this point in the history
Introduce `attributeConflictValidator`
  • Loading branch information
chrismarget-j authored Sep 22, 2023
2 parents 48f5b17 + 6c51e89 commit 398e402
Show file tree
Hide file tree
Showing 2 changed files with 813 additions and 0 deletions.
237 changes: 237 additions & 0 deletions apstra/apstra_validator/attribute_conflict.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
package apstravalidator

import (
"context"
"encoding/base64"
"fmt"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
"strings"
)

type CollectionValidator interface {
validator.List
validator.Map
validator.Set
}

var _ CollectionValidator = attributeConflictValidator{}

// attributeConflictValidator ensures that no two elements of a list, map, or
// set of objects use the same value across all attributes enumerated in
// keyAttrs.
//
// For example, if keyAttrs contains just {"name"}, then having two objects
// with `name: "foo"` will produce a validation error.
//
// If keyAttrs contains {"protocol", "port"} then having two objects with
// `protocol: "TCP"` and `port: 80` will produce a validation error.
//
// If keyAttrs is empty, then values across all attributes are evaluated.
type attributeConflictValidator struct {
keyAttrs []string
caseInsensitive bool
}

func (o attributeConflictValidator) Description(_ context.Context) string {
if len(o.keyAttrs) == 0 {
return "Ensure that no two collection (list/map/set) members share values for all attributes"
}

return fmt.Sprintf(
"Ensure that no two collection (list/map/set) members share values for these attributes: [%s]",
strings.Join(o.keyAttrs, " "),
)
}

func (o attributeConflictValidator) MarkdownDescription(ctx context.Context) string {
return o.Description(ctx)
}

func (o attributeConflictValidator) ValidateList(ctx context.Context, req validator.ListRequest, resp *validator.ListResponse) {
if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() {
return
}

foundKeyValueCombinations := make(map[string]bool)
for i, element := range req.ConfigValue.Elements() { // loop over set members
validateRequest := attributeConflictValidateElementRequest{
elementValue: element,
elementPath: req.Path.AtListIndex(i),
foundKeyValueCombinations: foundKeyValueCombinations,
path: req.Path,
}
validateResponse := attributeConflictValidateElementResponse{}
o.validateElement(ctx, validateRequest, &validateResponse)
resp.Diagnostics.Append(validateResponse.Diagnostics...)
}
}

func (o attributeConflictValidator) ValidateMap(ctx context.Context, req validator.MapRequest, resp *validator.MapResponse) {
if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() {
return
}

foundKeyValueCombinations := make(map[string]bool)
for mapKey, element := range req.ConfigValue.Elements() { // loop over set members
validateRequest := attributeConflictValidateElementRequest{
elementValue: element,
elementPath: req.Path.AtMapKey(mapKey),
foundKeyValueCombinations: foundKeyValueCombinations,
path: req.Path,
}
validateResponse := attributeConflictValidateElementResponse{}
o.validateElement(ctx, validateRequest, &validateResponse)
resp.Diagnostics.Append(validateResponse.Diagnostics...)
}
}

func (o attributeConflictValidator) ValidateSet(ctx context.Context, req validator.SetRequest, resp *validator.SetResponse) {
if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() {
return
}

foundKeyValueCombinations := make(map[string]bool)
for _, element := range req.ConfigValue.Elements() { // loop over set members
validateRequest := attributeConflictValidateElementRequest{
elementValue: element,
elementPath: req.Path.AtSetValue(element),
foundKeyValueCombinations: foundKeyValueCombinations,
path: req.Path,
}
validateResponse := attributeConflictValidateElementResponse{}
o.validateElement(ctx, validateRequest, &validateResponse)
resp.Diagnostics.Append(validateResponse.Diagnostics...)
}
}

type attributeConflictValidateElementRequest struct {
elementValue attr.Value
elementPath path.Path
foundKeyValueCombinations map[string]bool
path path.Path
}

type attributeConflictValidateElementResponse struct {
Diagnostics diag.Diagnostics
}

func (o *attributeConflictValidator) validateElement(ctx context.Context, req attributeConflictValidateElementRequest, resp *attributeConflictValidateElementResponse) {
objectValuable, ok := req.elementValue.(basetypes.ObjectValuable)
if !ok {
resp.Diagnostics.AddAttributeError(
req.path,
"Invalid Validator for Element Value",
"While performing schema-based validation, an unexpected error occurred. "+
"The attribute declares a Object values validator, however its values do not implement the types.ObjectValuable interface for custom Object types. "+
"This is likely an issue with terraform-plugin-framework and should be reported to the provider developers.\n\n"+
fmt.Sprintf("Path: %s\n", req.path.String())+
fmt.Sprintf("Element Type: %T\n", req.elementValue.Type(ctx))+
fmt.Sprintf("Element Value Type: %T\n", req.elementValue),
)

return
}

objectValue, d := objectValuable.ToObjectValue(ctx)
resp.Diagnostics.Append(d...)
if resp.Diagnostics.HasError() {
return
}

// if the caller didn't specify any "key" attributes we use all of them
if len(o.keyAttrs) == 0 {
for k := range objectValue.Attributes() {
o.keyAttrs = append(o.keyAttrs, k)
}
}

// map of key attribute names used to quickly recognize whether an attribute is interesting
keyAttributeNames := make(map[string]bool, len(o.keyAttrs))
for _, key := range o.keyAttrs {
keyAttributeNames[key] = true
}

keyValuesMap := make(map[string]string, len(keyAttributeNames))
for attrName, attrValue := range objectValue.Attributes() { // loop over set member attributes
if !keyAttributeNames[attrName] {
continue // attribute is not interesting
}

if attrValue.IsUnknown() {
return // cannot validate when attribute is unknown
}

var valueToCompare string // a configured value we're checking for unique-ness
if o.caseInsensitive {
valueToCompare = strings.ToLower(attrValue.String())
} else {
valueToCompare = attrValue.String()
}

keyValuesMap[attrName] = base64.StdEncoding.EncodeToString([]byte(valueToCompare))
if len(keyValuesMap) == len(keyAttributeNames) {
break // keyValuesMap is full, no need to look at remaining attributes
}
}

// did we find all of the required "key attributes" ?
if len(keyValuesMap) < len(keyAttributeNames) {
// collect object's attribute names so we can complain about them
var attrNames []string
for attrName := range objectValue.Attributes() {
attrNames = append(attrNames, attrName)
}

resp.Diagnostics.AddAttributeError(
req.path,
"Invalid Validator for Element Value",
"While performing schema-based validation, an unexpected error occurred. "+
"The attribute declares an Object values validator which has been asked "+
"to validate attributes not present in the object. "+
"This issue should be reported to the provider developers.\n\n"+
fmt.Sprintf("Path: %s\n", req.path.String())+
fmt.Sprintf("Element Attributes: '%s'\n", strings.Join(attrNames, "', '"))+
fmt.Sprintf("Element Attributes to validate: '%s'\n", strings.Join(o.keyAttrs, "', '")),
)

return
}

sb := strings.Builder{}
for i := range o.keyAttrs {
if i == 0 {
sb.WriteString(keyValuesMap[o.keyAttrs[i]])
} else {
sb.WriteString(":" + keyValuesMap[o.keyAttrs[i]])
}
}

if req.foundKeyValueCombinations[sb.String()] { // seen this value before?
resp.Diagnostics.AddAttributeError(
req.elementPath,
fmt.Sprintf("%s collision", o.keyAttrs),
fmt.Sprintf("Two objects cannot use the same value "+
"combination for these attributes: ['%s'] (case sensitive: %t)",
strings.Join(o.keyAttrs, "', '"), o.caseInsensitive),
)
} else {
req.foundKeyValueCombinations[sb.String()] = true // log the name for future collision checks
}
}

func UniqueValueCombinationsAt(attrNames ...string) CollectionValidator {
return attributeConflictValidator{
keyAttrs: attrNames,
}
}

func UniqueInsensitiveValueCombinationsAt(attrNames ...string) CollectionValidator {
return attributeConflictValidator{
keyAttrs: attrNames,
caseInsensitive: true,
}
}
Loading

0 comments on commit 398e402

Please sign in to comment.