diff --git a/.changelog/60.txt b/.changelog/60.txt new file mode 100644 index 00000000..78ffde25 --- /dev/null +++ b/.changelog/60.txt @@ -0,0 +1,11 @@ +```release-note:feature +Introduced `datasourcevalidator` package with `AtLeastOneOf()`, `Conflicting()`, `ExactlyOneOf()`, and `RequiredTogether()` validation functions +``` + +```release-note:feature +Introduced `providervalidator` package with `AtLeastOneOf()`, `Conflicting()`, `ExactlyOneOf()`, and `RequiredTogether()` validation functions +``` + +```release-note:feature +Introduced `resourcevalidator` package with `AtLeastOneOf()`, `Conflicting()`, `ExactlyOneOf()`, and `RequiredTogether()` validation functions +``` diff --git a/datasourcevalidator/at_least_one_of.go b/datasourcevalidator/at_least_one_of.go new file mode 100644 index 00000000..00120536 --- /dev/null +++ b/datasourcevalidator/at_least_one_of.go @@ -0,0 +1,15 @@ +package datasourcevalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/configvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/path" +) + +// AtLeastOneOf checks that a set of path.Expression has at least one non-null +// or unknown value. +func AtLeastOneOf(expressions ...path.Expression) datasource.ConfigValidator { + return &configvalidator.AtLeastOneOfValidator{ + PathExpressions: expressions, + } +} diff --git a/datasourcevalidator/at_least_one_of_example_test.go b/datasourcevalidator/at_least_one_of_example_test.go new file mode 100644 index 00000000..2595bad0 --- /dev/null +++ b/datasourcevalidator/at_least_one_of_example_test.go @@ -0,0 +1,19 @@ +package datasourcevalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/datasourcevalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/path" +) + +func ExampleAtLeastOneOf() { + // Used inside a datasource.DataSource type ConfigValidators method + _ = []datasource.ConfigValidator{ + // Validate at least one of the schema defined attributes named attr1 + // and attr2 has a known, non-null value. + datasourcevalidator.AtLeastOneOf( + path.MatchRoot("attr1"), + path.MatchRoot("attr2"), + ), + } +} diff --git a/datasourcevalidator/at_least_one_of_test.go b/datasourcevalidator/at_least_one_of_test.go new file mode 100644 index 00000000..6a7bc652 --- /dev/null +++ b/datasourcevalidator/at_least_one_of_test.go @@ -0,0 +1,125 @@ +package datasourcevalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework-validators/datasourcevalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestAtLeastOneOf(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + pathExpressions path.Expressions + req datasource.ValidateConfigRequest + expected *datasource.ValidateConfigResponse + }{ + "no-diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + req: datasource.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &datasource.ValidateConfigResponse{}, + }, + "diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + req: datasource.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, nil), + "test2": tftypes.NewValue(tftypes.String, nil), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &datasource.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Missing Attribute Configuration", + "At least one of these attributes must be configured: [test1,test2]", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + validator := datasourcevalidator.AtLeastOneOf(testCase.pathExpressions...) + got := &datasource.ValidateConfigResponse{} + + validator.ValidateDataSource(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/datasourcevalidator/conflicting.go b/datasourcevalidator/conflicting.go new file mode 100644 index 00000000..318bc6ff --- /dev/null +++ b/datasourcevalidator/conflicting.go @@ -0,0 +1,15 @@ +package datasourcevalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/configvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/path" +) + +// Conflicting checks that a set of path.Expression, are not configured +// simultaneously. +func Conflicting(expressions ...path.Expression) datasource.ConfigValidator { + return &configvalidator.ConflictingValidator{ + PathExpressions: expressions, + } +} diff --git a/datasourcevalidator/conflicting_example_test.go b/datasourcevalidator/conflicting_example_test.go new file mode 100644 index 00000000..ab72f5f9 --- /dev/null +++ b/datasourcevalidator/conflicting_example_test.go @@ -0,0 +1,19 @@ +package datasourcevalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/datasourcevalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/path" +) + +func ExampleConflicting() { + // Used inside a datasource.DataSource type ConfigValidators method + _ = []datasource.ConfigValidator{ + // Validate that schema defined attributes named attr1 and attr2 are not + // both configured with known, non-null values. + datasourcevalidator.Conflicting( + path.MatchRoot("attr1"), + path.MatchRoot("attr2"), + ), + } +} diff --git a/datasourcevalidator/conflicting_test.go b/datasourcevalidator/conflicting_test.go new file mode 100644 index 00000000..5e0f552c --- /dev/null +++ b/datasourcevalidator/conflicting_test.go @@ -0,0 +1,126 @@ +package datasourcevalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework-validators/datasourcevalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestConflicting(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + pathExpressions path.Expressions + req datasource.ValidateConfigRequest + expected *datasource.ValidateConfigResponse + }{ + "no-diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + req: datasource.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &datasource.ValidateConfigResponse{}, + }, + "diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + req: datasource.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, "test-value"), + "test2": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &datasource.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test1"), + "Invalid Attribute Combination", + "These attributes cannot be configured together: [test1,test2]", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + validator := datasourcevalidator.Conflicting(testCase.pathExpressions...) + got := &datasource.ValidateConfigResponse{} + + validator.ValidateDataSource(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/datasourcevalidator/doc.go b/datasourcevalidator/doc.go new file mode 100644 index 00000000..75dffec7 --- /dev/null +++ b/datasourcevalidator/doc.go @@ -0,0 +1,10 @@ +// Package datasourcevalidator provides validators to express relationships +// between multiple attributes of a data source. For example, checking that +// multiple attributes are not configured at the same time. +// +// These validators are implemented outside the schema, which may be easier to +// implement in provider code generation situations or suit provider code +// preferences differently than those in the schemavalidator package. Those +// validators start on a starting attribute, where relationships can be +// expressed as absolute paths to others or relative to the starting attribute. +package datasourcevalidator diff --git a/datasourcevalidator/exactly_one_of.go b/datasourcevalidator/exactly_one_of.go new file mode 100644 index 00000000..7642208c --- /dev/null +++ b/datasourcevalidator/exactly_one_of.go @@ -0,0 +1,15 @@ +package datasourcevalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/configvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/path" +) + +// ExactlyOneOf checks that a set of path.Expression does not have more than +// one known value. +func ExactlyOneOf(expressions ...path.Expression) datasource.ConfigValidator { + return &configvalidator.ExactlyOneOfValidator{ + PathExpressions: expressions, + } +} diff --git a/datasourcevalidator/exactly_one_of_example_test.go b/datasourcevalidator/exactly_one_of_example_test.go new file mode 100644 index 00000000..328a7e12 --- /dev/null +++ b/datasourcevalidator/exactly_one_of_example_test.go @@ -0,0 +1,19 @@ +package datasourcevalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/datasourcevalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/path" +) + +func ExampleExactlyOneOf() { + // Used inside a datasource.DataSource type ConfigValidators method + _ = []datasource.ConfigValidator{ + // Validate only one of the schema defined attributes named attr1 + // and attr2 has a known, non-null value. + datasourcevalidator.ExactlyOneOf( + path.MatchRoot("attr1"), + path.MatchRoot("attr2"), + ), + } +} diff --git a/datasourcevalidator/exactly_one_of_test.go b/datasourcevalidator/exactly_one_of_test.go new file mode 100644 index 00000000..4fbaf429 --- /dev/null +++ b/datasourcevalidator/exactly_one_of_test.go @@ -0,0 +1,126 @@ +package datasourcevalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework-validators/datasourcevalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestExactlyOneOf(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + pathExpressions path.Expressions + req datasource.ValidateConfigRequest + expected *datasource.ValidateConfigResponse + }{ + "no-diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + req: datasource.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &datasource.ValidateConfigResponse{}, + }, + "diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + req: datasource.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, "test-value"), + "test2": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &datasource.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test1"), + "Invalid Attribute Combination", + "Exactly one of these attributes must be configured: [test1,test2]", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + validator := datasourcevalidator.ExactlyOneOf(testCase.pathExpressions...) + got := &datasource.ValidateConfigResponse{} + + validator.ValidateDataSource(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/datasourcevalidator/required_together.go b/datasourcevalidator/required_together.go new file mode 100644 index 00000000..b5ea0ea2 --- /dev/null +++ b/datasourcevalidator/required_together.go @@ -0,0 +1,15 @@ +package datasourcevalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/configvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/path" +) + +// RequiredTogether checks that a set of path.Expression either has all known +// or all null values. +func RequiredTogether(expressions ...path.Expression) datasource.ConfigValidator { + return &configvalidator.RequiredTogetherValidator{ + PathExpressions: expressions, + } +} diff --git a/datasourcevalidator/required_together_example_test.go b/datasourcevalidator/required_together_example_test.go new file mode 100644 index 00000000..f591dbd1 --- /dev/null +++ b/datasourcevalidator/required_together_example_test.go @@ -0,0 +1,19 @@ +package datasourcevalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/datasourcevalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/path" +) + +func ExampleRequiredTogether() { + // Used inside a datasource.DataSource type ConfigValidators method + _ = []datasource.ConfigValidator{ + // Validate the schema defined attributes named attr1 and attr2 are either + // both null or both known values. + datasourcevalidator.RequiredTogether( + path.MatchRoot("attr1"), + path.MatchRoot("attr2"), + ), + } +} diff --git a/datasourcevalidator/required_together_test.go b/datasourcevalidator/required_together_test.go new file mode 100644 index 00000000..c0b465e6 --- /dev/null +++ b/datasourcevalidator/required_together_test.go @@ -0,0 +1,126 @@ +package datasourcevalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework-validators/datasourcevalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestRequiredTogether(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + pathExpressions path.Expressions + req datasource.ValidateConfigRequest + expected *datasource.ValidateConfigResponse + }{ + "no-diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + req: datasource.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &datasource.ValidateConfigResponse{}, + }, + "diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + req: datasource.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, "test-value"), + "test2": tftypes.NewValue(tftypes.String, nil), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &datasource.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test1"), + "Invalid Attribute Combination", + "These attributes must be configured together: [test1,test2]", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + validator := datasourcevalidator.RequiredTogether(testCase.pathExpressions...) + got := &datasource.ValidateConfigResponse{} + + validator.ValidateDataSource(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/internal/configvalidator/at_least_one_of.go b/internal/configvalidator/at_least_one_of.go new file mode 100644 index 00000000..32cee174 --- /dev/null +++ b/internal/configvalidator/at_least_one_of.go @@ -0,0 +1,106 @@ +package configvalidator + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" +) + +var _ datasource.ConfigValidator = &AtLeastOneOfValidator{} +var _ provider.ConfigValidator = &AtLeastOneOfValidator{} +var _ resource.ConfigValidator = &AtLeastOneOfValidator{} + +// AtLeastOneOfValidator is the underlying struct implementing AtLeastOneOf. +type AtLeastOneOfValidator struct { + PathExpressions path.Expressions +} + +func (v AtLeastOneOfValidator) Description(ctx context.Context) string { + return v.MarkdownDescription(ctx) +} + +func (v AtLeastOneOfValidator) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("At least one of these attributes must be configured: %s", v.PathExpressions) +} + +func (v AtLeastOneOfValidator) ValidateDataSource(ctx context.Context, req datasource.ValidateConfigRequest, resp *datasource.ValidateConfigResponse) { + resp.Diagnostics = v.Validate(ctx, req.Config) +} + +func (v AtLeastOneOfValidator) ValidateProvider(ctx context.Context, req provider.ValidateConfigRequest, resp *provider.ValidateConfigResponse) { + resp.Diagnostics = v.Validate(ctx, req.Config) +} + +func (v AtLeastOneOfValidator) ValidateResource(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) { + resp.Diagnostics = v.Validate(ctx, req.Config) +} + +func (v AtLeastOneOfValidator) Validate(ctx context.Context, config tfsdk.Config) diag.Diagnostics { + var configuredPaths, unknownPaths path.Paths + var diags diag.Diagnostics + + for _, expression := range v.PathExpressions { + matchedPaths, matchedPathsDiags := config.PathMatches(ctx, expression) + + diags.Append(matchedPathsDiags...) + + // Collect all errors + if matchedPathsDiags.HasError() { + continue + } + + for _, matchedPath := range matchedPaths { + var value attr.Value + getAttributeDiags := config.GetAttribute(ctx, matchedPath, &value) + + diags.Append(getAttributeDiags...) + + // Collect all errors + if getAttributeDiags.HasError() { + continue + } + + // If value is unknown, it may be null or a value, so we cannot + // know if the validator should succeed or not. Collect the path + // path so we use it to skip the validation later and continue to + // collect all path matching diagnostics. + if value.IsUnknown() { + unknownPaths.Append(matchedPath) + continue + } + + // If value is null, move onto the next one. + if value.IsNull() { + continue + } + + // Value is known and not null, it is configured. + configuredPaths.Append(matchedPath) + } + } + + // If there are unknown values, we cannot know if the validator should + // succeed or not. + if len(unknownPaths) > 0 { + return diags + } + + // Only return missing attribute configuration when error diagnostics are + // not present, since they likely represent a provider developer mistake, + // such as an invalid path expression. + if len(configuredPaths) == 0 && !diags.HasError() { + diags.Append(diag.NewErrorDiagnostic( + "Missing Attribute Configuration", + v.Description(ctx), + )) + } + + return diags +} diff --git a/internal/configvalidator/at_least_one_of_test.go b/internal/configvalidator/at_least_one_of_test.go new file mode 100644 index 00000000..606bc8d9 --- /dev/null +++ b/internal/configvalidator/at_least_one_of_test.go @@ -0,0 +1,984 @@ +package configvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework-validators/internal/configvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestAtLeastOneOfValidatorValidate(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + validator configvalidator.AtLeastOneOfValidator + config tfsdk.Config + expected diag.Diagnostics + }{ + "nil-path-expressions": { + validator: configvalidator.AtLeastOneOfValidator{ + PathExpressions: nil, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Missing Attribute Configuration", + "At least one of these attributes must be configured: []", + ), + }, + }, + "empty-path-expressions": { + validator: configvalidator.AtLeastOneOfValidator{ + PathExpressions: path.Expressions{}, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Missing Attribute Configuration", + "At least one of these attributes must be configured: []", + ), + }, + }, + "one-non-existent-path-expression": { + validator: configvalidator.AtLeastOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("not-test"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Path Expression for Schema Data", + "The Terraform Provider unexpectedly matched no paths with the given path expression and current schema data. "+ + "This can happen if the path expression does not correctly follow the schema in structure or types. "+ + "Please report this to the provider developers.\n\n"+ + "Path Expression: not-test", + ), + }, + }, + "two-non-existent-path-expression": { + validator: configvalidator.AtLeastOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("not-test1"), + path.MatchRoot("not-test2"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Path Expression for Schema Data", + "The Terraform Provider unexpectedly matched no paths with the given path expression and current schema data. "+ + "This can happen if the path expression does not correctly follow the schema in structure or types. "+ + "Please report this to the provider developers.\n\n"+ + "Path Expression: not-test1", + ), + diag.NewErrorDiagnostic( + "Invalid Path Expression for Schema Data", + "The Terraform Provider unexpectedly matched no paths with the given path expression and current schema data. "+ + "This can happen if the path expression does not correctly follow the schema in structure or types. "+ + "Please report this to the provider developers.\n\n"+ + "Path Expression: not-test2", + ), + }, + }, + "one-matching-path-expression-null": { + validator: configvalidator.AtLeastOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, nil), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Missing Attribute Configuration", + "At least one of these attributes must be configured: [test]", + ), + }, + }, + "one-matching-path-expression-unknown": { + validator: configvalidator.AtLeastOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: nil, + }, + "one-matching-path-expression-value": { + validator: configvalidator.AtLeastOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: nil, + }, + "two-matching-path-expression-one-null-one-value": { + validator: configvalidator.AtLeastOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, nil), + "test2": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: nil, + }, + "two-matching-path-expression-one-unknown-one-value": { + validator: configvalidator.AtLeastOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test2": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: nil, + }, + "two-matching-path-expression-two-null": { + validator: configvalidator.AtLeastOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, nil), + "test2": tftypes.NewValue(tftypes.String, nil), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Missing Attribute Configuration", + "At least one of these attributes must be configured: [test1,test2]", + ), + }, + }, + "two-matching-path-expression-two-unknown": { + validator: configvalidator.AtLeastOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test2": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: nil, + }, + "two-matching-path-expression-two-value": { + validator: configvalidator.AtLeastOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, "test-value"), + "test2": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: nil, + }, + "three-matching-path-expression-two-value-one-null": { + validator: configvalidator.AtLeastOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + path.MatchRoot("test3"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "test3": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "test3": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, "test-value"), + "test2": tftypes.NewValue(tftypes.String, "test-value"), + "test3": tftypes.NewValue(tftypes.String, nil), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: nil, + }, + "three-matching-path-expression-two-value-one-unknown": { + validator: configvalidator.AtLeastOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + path.MatchRoot("test3"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "test3": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "test3": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, "test-value"), + "test2": tftypes.NewValue(tftypes.String, "test-value"), + "test3": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: nil, + }, + "three-matching-path-expression-three-value": { + validator: configvalidator.AtLeastOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + path.MatchRoot("test3"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "test3": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "test3": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, "test-value"), + "test2": tftypes.NewValue(tftypes.String, "test-value"), + "test3": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: nil, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.validator.Validate(context.Background(), testCase.config) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestAtLeastOneOfValidatorValidateDataSource(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + validator configvalidator.AtLeastOneOfValidator + req datasource.ValidateConfigRequest + expected *datasource.ValidateConfigResponse + }{ + "no-diagnostics": { + validator: configvalidator.AtLeastOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + }, + req: datasource.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &datasource.ValidateConfigResponse{}, + }, + "diagnostics": { + validator: configvalidator.AtLeastOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + }, + req: datasource.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, nil), + "test2": tftypes.NewValue(tftypes.String, nil), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &datasource.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Missing Attribute Configuration", + "At least one of these attributes must be configured: [test1,test2]", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &datasource.ValidateConfigResponse{} + + testCase.validator.ValidateDataSource(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestAtLeastOneOfValidatorValidateProvider(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + validator configvalidator.AtLeastOneOfValidator + req provider.ValidateConfigRequest + expected *provider.ValidateConfigResponse + }{ + "no-diagnostics": { + validator: configvalidator.AtLeastOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + }, + req: provider.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &provider.ValidateConfigResponse{}, + }, + "diagnostics": { + validator: configvalidator.AtLeastOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + }, + req: provider.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, nil), + "test2": tftypes.NewValue(tftypes.String, nil), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &provider.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Missing Attribute Configuration", + "At least one of these attributes must be configured: [test1,test2]", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &provider.ValidateConfigResponse{} + + testCase.validator.ValidateProvider(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestAtLeastOneOfValidatorValidateResource(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + validator configvalidator.AtLeastOneOfValidator + req resource.ValidateConfigRequest + expected *resource.ValidateConfigResponse + }{ + "no-diagnostics": { + validator: configvalidator.AtLeastOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + }, + req: resource.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &resource.ValidateConfigResponse{}, + }, + "diagnostics": { + validator: configvalidator.AtLeastOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + }, + req: resource.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, nil), + "test2": tftypes.NewValue(tftypes.String, nil), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &resource.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Missing Attribute Configuration", + "At least one of these attributes must be configured: [test1,test2]", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &resource.ValidateConfigResponse{} + + testCase.validator.ValidateResource(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/internal/configvalidator/conflicting.go b/internal/configvalidator/conflicting.go new file mode 100644 index 00000000..85499cf2 --- /dev/null +++ b/internal/configvalidator/conflicting.go @@ -0,0 +1,88 @@ +package configvalidator + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" +) + +var _ datasource.ConfigValidator = &ConflictingValidator{} +var _ provider.ConfigValidator = &ConflictingValidator{} +var _ resource.ConfigValidator = &ConflictingValidator{} + +// ConflictingValidator is the underlying struct implementing ConflictsWith. +type ConflictingValidator struct { + PathExpressions path.Expressions +} + +func (v ConflictingValidator) Description(ctx context.Context) string { + return v.MarkdownDescription(ctx) +} + +func (v ConflictingValidator) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("These attributes cannot be configured together: %s", v.PathExpressions) +} + +func (v ConflictingValidator) ValidateDataSource(ctx context.Context, req datasource.ValidateConfigRequest, resp *datasource.ValidateConfigResponse) { + resp.Diagnostics = v.Validate(ctx, req.Config) +} + +func (v ConflictingValidator) ValidateProvider(ctx context.Context, req provider.ValidateConfigRequest, resp *provider.ValidateConfigResponse) { + resp.Diagnostics = v.Validate(ctx, req.Config) +} + +func (v ConflictingValidator) ValidateResource(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) { + resp.Diagnostics = v.Validate(ctx, req.Config) +} + +func (v ConflictingValidator) Validate(ctx context.Context, config tfsdk.Config) diag.Diagnostics { + var configuredPaths path.Paths + var diags diag.Diagnostics + + for _, expression := range v.PathExpressions { + matchedPaths, matchedPathsDiags := config.PathMatches(ctx, expression) + + diags.Append(matchedPathsDiags...) + + // Collect all errors + if matchedPathsDiags.HasError() { + continue + } + + for _, matchedPath := range matchedPaths { + var value attr.Value + getAttributeDiags := config.GetAttribute(ctx, matchedPath, &value) + + diags.Append(getAttributeDiags...) + + // Collect all errors + if getAttributeDiags.HasError() { + continue + } + + // Value must not be null or unknown to trigger validation error + if value.IsNull() || value.IsUnknown() { + continue + } + + configuredPaths.Append(matchedPath) + } + } + + if len(configuredPaths) > 1 { + diags.Append(validatordiag.InvalidAttributeCombinationDiagnostic( + configuredPaths[0], + v.Description(ctx), + )) + } + + return diags +} diff --git a/internal/configvalidator/conflicting_test.go b/internal/configvalidator/conflicting_test.go new file mode 100644 index 00000000..62bb2e3d --- /dev/null +++ b/internal/configvalidator/conflicting_test.go @@ -0,0 +1,991 @@ +package configvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework-validators/internal/configvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestConflictingValidatorValidate(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + validator configvalidator.ConflictingValidator + config tfsdk.Config + expected diag.Diagnostics + }{ + "nil-path-expressions": { + validator: configvalidator.ConflictingValidator{ + PathExpressions: nil, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: nil, + }, + "empty-path-expressions": { + validator: configvalidator.ConflictingValidator{ + PathExpressions: path.Expressions{}, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: nil, + }, + "one-non-existent-path-expression": { + validator: configvalidator.ConflictingValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("not-test"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Path Expression for Schema Data", + "The Terraform Provider unexpectedly matched no paths with the given path expression and current schema data. "+ + "This can happen if the path expression does not correctly follow the schema in structure or types. "+ + "Please report this to the provider developers.\n\n"+ + "Path Expression: not-test", + ), + }, + }, + "two-non-existent-path-expression": { + validator: configvalidator.ConflictingValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("not-test1"), + path.MatchRoot("not-test2"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Path Expression for Schema Data", + "The Terraform Provider unexpectedly matched no paths with the given path expression and current schema data. "+ + "This can happen if the path expression does not correctly follow the schema in structure or types. "+ + "Please report this to the provider developers.\n\n"+ + "Path Expression: not-test1", + ), + diag.NewErrorDiagnostic( + "Invalid Path Expression for Schema Data", + "The Terraform Provider unexpectedly matched no paths with the given path expression and current schema data. "+ + "This can happen if the path expression does not correctly follow the schema in structure or types. "+ + "Please report this to the provider developers.\n\n"+ + "Path Expression: not-test2", + ), + }, + }, + "one-matching-path-expression-null": { + validator: configvalidator.ConflictingValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, nil), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: nil, + }, + "one-matching-path-expression-unknown": { + validator: configvalidator.ConflictingValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: nil, + }, + "one-matching-path-expression-value": { + validator: configvalidator.ConflictingValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: nil, + }, + "two-matching-path-expression-one-null-one-value": { + validator: configvalidator.ConflictingValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, nil), + "test2": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: nil, + }, + "two-matching-path-expression-one-unknown-one-value": { + validator: configvalidator.ConflictingValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test2": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: nil, + }, + "two-matching-path-expression-two-null": { + validator: configvalidator.ConflictingValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, nil), + "test2": tftypes.NewValue(tftypes.String, nil), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: nil, + }, + "two-matching-path-expression-two-unknown": { + validator: configvalidator.ConflictingValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test2": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: nil, + }, + "two-matching-path-expression-two-value": { + validator: configvalidator.ConflictingValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, "test-value"), + "test2": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test1"), + "Invalid Attribute Combination", + "These attributes cannot be configured together: [test1,test2]", + ), + }, + }, + "three-matching-path-expression-two-value-one-null": { + validator: configvalidator.ConflictingValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + path.MatchRoot("test3"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "test3": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "test3": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, "test-value"), + "test2": tftypes.NewValue(tftypes.String, "test-value"), + "test3": tftypes.NewValue(tftypes.String, nil), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test1"), + "Invalid Attribute Combination", + "These attributes cannot be configured together: [test1,test2,test3]", + ), + }, + }, + "three-matching-path-expression-two-value-one-unknown": { + validator: configvalidator.ConflictingValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + path.MatchRoot("test3"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "test3": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "test3": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, "test-value"), + "test2": tftypes.NewValue(tftypes.String, "test-value"), + "test3": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test1"), + "Invalid Attribute Combination", + "These attributes cannot be configured together: [test1,test2,test3]", + ), + }, + }, + "three-matching-path-expression-three-value": { + validator: configvalidator.ConflictingValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + path.MatchRoot("test3"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "test3": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "test3": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, "test-value"), + "test2": tftypes.NewValue(tftypes.String, "test-value"), + "test3": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test1"), + "Invalid Attribute Combination", + "These attributes cannot be configured together: [test1,test2,test3]", + ), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.validator.Validate(context.Background(), testCase.config) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestConflictingValidatorValidateDataSource(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + validator configvalidator.ConflictingValidator + req datasource.ValidateConfigRequest + expected *datasource.ValidateConfigResponse + }{ + "no-diagnostics": { + validator: configvalidator.ConflictingValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + }, + req: datasource.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &datasource.ValidateConfigResponse{}, + }, + "diagnostics": { + validator: configvalidator.ConflictingValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + }, + req: datasource.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, "test-value"), + "test2": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &datasource.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test1"), + "Invalid Attribute Combination", + "These attributes cannot be configured together: [test1,test2]", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &datasource.ValidateConfigResponse{} + + testCase.validator.ValidateDataSource(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestConflictingValidatorValidateProvider(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + validator configvalidator.ConflictingValidator + req provider.ValidateConfigRequest + expected *provider.ValidateConfigResponse + }{ + "no-diagnostics": { + validator: configvalidator.ConflictingValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + }, + req: provider.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &provider.ValidateConfigResponse{}, + }, + "diagnostics": { + validator: configvalidator.ConflictingValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + }, + req: provider.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, "test-value"), + "test2": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &provider.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test1"), + "Invalid Attribute Combination", + "These attributes cannot be configured together: [test1,test2]", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &provider.ValidateConfigResponse{} + + testCase.validator.ValidateProvider(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestConflictingValidatorValidateResource(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + validator configvalidator.ConflictingValidator + req resource.ValidateConfigRequest + expected *resource.ValidateConfigResponse + }{ + "no-diagnostics": { + validator: configvalidator.ConflictingValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + }, + req: resource.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &resource.ValidateConfigResponse{}, + }, + "diagnostics": { + validator: configvalidator.ConflictingValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + }, + req: resource.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, "test-value"), + "test2": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &resource.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test1"), + "Invalid Attribute Combination", + "These attributes cannot be configured together: [test1,test2]", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &resource.ValidateConfigResponse{} + + testCase.validator.ValidateResource(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/internal/configvalidator/doc.go b/internal/configvalidator/doc.go new file mode 100644 index 00000000..b1bed4ac --- /dev/null +++ b/internal/configvalidator/doc.go @@ -0,0 +1,4 @@ +// Package configvalidator provides the generic configuration validator +// implementations for the exported datasourcevalidator, providervalidator, and +// resourcevalidator packages. +package configvalidator diff --git a/internal/configvalidator/exactly_one_of.go b/internal/configvalidator/exactly_one_of.go new file mode 100644 index 00000000..477a8f24 --- /dev/null +++ b/internal/configvalidator/exactly_one_of.go @@ -0,0 +1,115 @@ +package configvalidator + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" +) + +var _ datasource.ConfigValidator = &ExactlyOneOfValidator{} +var _ provider.ConfigValidator = &ExactlyOneOfValidator{} +var _ resource.ConfigValidator = &ExactlyOneOfValidator{} + +// ExactlyOneOfValidator is the underlying struct implementing ExactlyOneOf. +type ExactlyOneOfValidator struct { + PathExpressions path.Expressions +} + +func (v ExactlyOneOfValidator) Description(ctx context.Context) string { + return v.MarkdownDescription(ctx) +} + +func (v ExactlyOneOfValidator) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("Exactly one of these attributes must be configured: %s", v.PathExpressions) +} + +func (v ExactlyOneOfValidator) ValidateDataSource(ctx context.Context, req datasource.ValidateConfigRequest, resp *datasource.ValidateConfigResponse) { + resp.Diagnostics = v.Validate(ctx, req.Config) +} + +func (v ExactlyOneOfValidator) ValidateProvider(ctx context.Context, req provider.ValidateConfigRequest, resp *provider.ValidateConfigResponse) { + resp.Diagnostics = v.Validate(ctx, req.Config) +} + +func (v ExactlyOneOfValidator) ValidateResource(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) { + resp.Diagnostics = v.Validate(ctx, req.Config) +} + +func (v ExactlyOneOfValidator) Validate(ctx context.Context, config tfsdk.Config) diag.Diagnostics { + var configuredPaths, unknownPaths path.Paths + var diags diag.Diagnostics + + for _, expression := range v.PathExpressions { + matchedPaths, matchedPathsDiags := config.PathMatches(ctx, expression) + + diags.Append(matchedPathsDiags...) + + // Collect all errors + if matchedPathsDiags.HasError() { + continue + } + + for _, matchedPath := range matchedPaths { + var value attr.Value + getAttributeDiags := config.GetAttribute(ctx, matchedPath, &value) + + diags.Append(getAttributeDiags...) + + // Collect all errors + if getAttributeDiags.HasError() { + continue + } + + // If value is unknown, it may be null or a value, so we cannot + // know if the validator should succeed or not. Collect the path + // path so we use it to skip the validation later and continue to + // collect all path matching diagnostics. + if value.IsUnknown() { + unknownPaths.Append(matchedPath) + continue + } + + // If value is null, move onto the next one. + if value.IsNull() { + continue + } + + // Value is known and not null, it is configured. + configuredPaths.Append(matchedPath) + } + } + + // We can always return an error if more than one path was configured. + if len(configuredPaths) > 1 { + diags.Append(validatordiag.InvalidAttributeCombinationDiagnostic( + configuredPaths[0], + v.Description(ctx), + )) + } + + // If there are unknown values, we cannot know if the validator should + // succeed or not. + if len(unknownPaths) > 0 { + return diags + } + + // Only return missing attribute configuration when error diagnostics are + // not present, since they likely represent a provider developer mistake, + // such as an invalid path expression. + if len(configuredPaths) == 0 && !diags.HasError() { + diags.Append(diag.NewErrorDiagnostic( + "Missing Attribute Configuration", + v.Description(ctx), + )) + } + + return diags +} diff --git a/internal/configvalidator/exactly_one_of_test.go b/internal/configvalidator/exactly_one_of_test.go new file mode 100644 index 00000000..4daa569a --- /dev/null +++ b/internal/configvalidator/exactly_one_of_test.go @@ -0,0 +1,1011 @@ +package configvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework-validators/internal/configvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestExactlyOneOfValidatorValidate(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + validator configvalidator.ExactlyOneOfValidator + config tfsdk.Config + expected diag.Diagnostics + }{ + "nil-path-expressions": { + validator: configvalidator.ExactlyOneOfValidator{ + PathExpressions: nil, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Missing Attribute Configuration", + "Exactly one of these attributes must be configured: []", + ), + }, + }, + "empty-path-expressions": { + validator: configvalidator.ExactlyOneOfValidator{ + PathExpressions: path.Expressions{}, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Missing Attribute Configuration", + "Exactly one of these attributes must be configured: []", + ), + }, + }, + "one-non-existent-path-expression": { + validator: configvalidator.ExactlyOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("not-test"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Path Expression for Schema Data", + "The Terraform Provider unexpectedly matched no paths with the given path expression and current schema data. "+ + "This can happen if the path expression does not correctly follow the schema in structure or types. "+ + "Please report this to the provider developers.\n\n"+ + "Path Expression: not-test", + ), + }, + }, + "two-non-existent-path-expression": { + validator: configvalidator.ExactlyOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("not-test1"), + path.MatchRoot("not-test2"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Path Expression for Schema Data", + "The Terraform Provider unexpectedly matched no paths with the given path expression and current schema data. "+ + "This can happen if the path expression does not correctly follow the schema in structure or types. "+ + "Please report this to the provider developers.\n\n"+ + "Path Expression: not-test1", + ), + diag.NewErrorDiagnostic( + "Invalid Path Expression for Schema Data", + "The Terraform Provider unexpectedly matched no paths with the given path expression and current schema data. "+ + "This can happen if the path expression does not correctly follow the schema in structure or types. "+ + "Please report this to the provider developers.\n\n"+ + "Path Expression: not-test2", + ), + }, + }, + "one-matching-path-expression-null": { + validator: configvalidator.ExactlyOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, nil), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Missing Attribute Configuration", + "Exactly one of these attributes must be configured: [test]", + ), + }, + }, + "one-matching-path-expression-unknown": { + validator: configvalidator.ExactlyOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: nil, + }, + "one-matching-path-expression-value": { + validator: configvalidator.ExactlyOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: nil, + }, + "two-matching-path-expression-one-null-one-value": { + validator: configvalidator.ExactlyOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, nil), + "test2": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: nil, + }, + "two-matching-path-expression-one-unknown-one-value": { + validator: configvalidator.ExactlyOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test2": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: nil, + }, + "two-matching-path-expression-two-null": { + validator: configvalidator.ExactlyOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, nil), + "test2": tftypes.NewValue(tftypes.String, nil), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Missing Attribute Configuration", + "Exactly one of these attributes must be configured: [test1,test2]", + ), + }, + }, + "two-matching-path-expression-two-unknown": { + validator: configvalidator.ExactlyOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test2": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: nil, + }, + "two-matching-path-expression-two-value": { + validator: configvalidator.ExactlyOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, "test-value"), + "test2": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test1"), + "Invalid Attribute Combination", + "Exactly one of these attributes must be configured: [test1,test2]", + ), + }, + }, + "three-matching-path-expression-two-value-one-null": { + validator: configvalidator.ExactlyOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + path.MatchRoot("test3"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "test3": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "test3": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, "test-value"), + "test2": tftypes.NewValue(tftypes.String, "test-value"), + "test3": tftypes.NewValue(tftypes.String, nil), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test1"), + "Invalid Attribute Combination", + "Exactly one of these attributes must be configured: [test1,test2,test3]", + ), + }, + }, + "three-matching-path-expression-two-value-one-unknown": { + validator: configvalidator.ExactlyOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + path.MatchRoot("test3"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "test3": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "test3": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, "test-value"), + "test2": tftypes.NewValue(tftypes.String, "test-value"), + "test3": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test1"), + "Invalid Attribute Combination", + "Exactly one of these attributes must be configured: [test1,test2,test3]", + ), + }, + }, + "three-matching-path-expression-three-value": { + validator: configvalidator.ExactlyOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + path.MatchRoot("test3"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "test3": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "test3": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, "test-value"), + "test2": tftypes.NewValue(tftypes.String, "test-value"), + "test3": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test1"), + "Invalid Attribute Combination", + "Exactly one of these attributes must be configured: [test1,test2,test3]", + ), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.validator.Validate(context.Background(), testCase.config) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestExactlyOneOfValidatorValidateDataSource(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + validator configvalidator.ExactlyOneOfValidator + req datasource.ValidateConfigRequest + expected *datasource.ValidateConfigResponse + }{ + "no-diagnostics": { + validator: configvalidator.ExactlyOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + }, + req: datasource.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &datasource.ValidateConfigResponse{}, + }, + "diagnostics": { + validator: configvalidator.ExactlyOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + }, + req: datasource.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, "test-value"), + "test2": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &datasource.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test1"), + "Invalid Attribute Combination", + "Exactly one of these attributes must be configured: [test1,test2]", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &datasource.ValidateConfigResponse{} + + testCase.validator.ValidateDataSource(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestExactlyOneOfValidatorValidateProvider(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + validator configvalidator.ExactlyOneOfValidator + req provider.ValidateConfigRequest + expected *provider.ValidateConfigResponse + }{ + "no-diagnostics": { + validator: configvalidator.ExactlyOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + }, + req: provider.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &provider.ValidateConfigResponse{}, + }, + "diagnostics": { + validator: configvalidator.ExactlyOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + }, + req: provider.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, "test-value"), + "test2": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &provider.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test1"), + "Invalid Attribute Combination", + "Exactly one of these attributes must be configured: [test1,test2]", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &provider.ValidateConfigResponse{} + + testCase.validator.ValidateProvider(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestExactlyOneOfValidatorValidateResource(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + validator configvalidator.ExactlyOneOfValidator + req resource.ValidateConfigRequest + expected *resource.ValidateConfigResponse + }{ + "no-diagnostics": { + validator: configvalidator.ExactlyOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + }, + req: resource.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &resource.ValidateConfigResponse{}, + }, + "diagnostics": { + validator: configvalidator.ExactlyOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + }, + req: resource.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, "test-value"), + "test2": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &resource.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test1"), + "Invalid Attribute Combination", + "Exactly one of these attributes must be configured: [test1,test2]", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &resource.ValidateConfigResponse{} + + testCase.validator.ValidateResource(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/internal/configvalidator/required_together.go b/internal/configvalidator/required_together.go new file mode 100644 index 00000000..c9186c87 --- /dev/null +++ b/internal/configvalidator/required_together.go @@ -0,0 +1,117 @@ +package configvalidator + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" +) + +var _ datasource.ConfigValidator = &RequiredTogetherValidator{} +var _ provider.ConfigValidator = &RequiredTogetherValidator{} +var _ resource.ConfigValidator = &RequiredTogetherValidator{} + +// RequiredTogetherValidator is the underlying struct implementing RequiredTogether. +type RequiredTogetherValidator struct { + PathExpressions path.Expressions +} + +func (v RequiredTogetherValidator) Description(ctx context.Context) string { + return v.MarkdownDescription(ctx) +} + +func (v RequiredTogetherValidator) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("These attributes must be configured together: %s", v.PathExpressions) +} + +func (v RequiredTogetherValidator) ValidateDataSource(ctx context.Context, req datasource.ValidateConfigRequest, resp *datasource.ValidateConfigResponse) { + resp.Diagnostics = v.Validate(ctx, req.Config) +} + +func (v RequiredTogetherValidator) ValidateProvider(ctx context.Context, req provider.ValidateConfigRequest, resp *provider.ValidateConfigResponse) { + resp.Diagnostics = v.Validate(ctx, req.Config) +} + +func (v RequiredTogetherValidator) ValidateResource(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) { + resp.Diagnostics = v.Validate(ctx, req.Config) +} + +func (v RequiredTogetherValidator) Validate(ctx context.Context, config tfsdk.Config) diag.Diagnostics { + var configuredPaths, foundPaths, unknownPaths path.Paths + var diags diag.Diagnostics + + for _, expression := range v.PathExpressions { + matchedPaths, matchedPathsDiags := config.PathMatches(ctx, expression) + + diags.Append(matchedPathsDiags...) + + // Collect all errors + if matchedPathsDiags.HasError() { + continue + } + + // Capture all matched paths so we can validate everything was either + // configured together or not. + foundPaths.Append(matchedPaths...) + + for _, matchedPath := range matchedPaths { + var value attr.Value + getAttributeDiags := config.GetAttribute(ctx, matchedPath, &value) + + diags.Append(getAttributeDiags...) + + // Collect all errors + if getAttributeDiags.HasError() { + continue + } + + // If value is unknown, it may be null or a value, so we cannot + // know if the validator should succeed or not. Collect the path + // path so we use it to skip the validation later and continue to + // collect all path matching diagnostics. + if value.IsUnknown() { + unknownPaths.Append(matchedPath) + continue + } + + // If value is null, move onto the next one. + if value.IsNull() { + continue + } + + // Value is known and not null, it is configured. + configuredPaths.Append(matchedPath) + } + } + + // Return early if all paths were null. + if len(configuredPaths) == 0 { + return diags + } + + // If there are unknown values, we cannot know if the validator should + // succeed or not. + if len(unknownPaths) > 0 { + return diags + } + + // If configured paths does not equal all matched paths, then something + // was missing. We compare the number of matched paths instead of path + // expressions to prevent false negatives with path expressions that match + // more than one path. + if len(configuredPaths) != len(foundPaths) { + diags.Append(validatordiag.InvalidAttributeCombinationDiagnostic( + configuredPaths[0], + v.Description(ctx), + )) + } + + return diags +} diff --git a/internal/configvalidator/required_together_test.go b/internal/configvalidator/required_together_test.go new file mode 100644 index 00000000..93d162fe --- /dev/null +++ b/internal/configvalidator/required_together_test.go @@ -0,0 +1,979 @@ +package configvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework-validators/internal/configvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestRequiredTogetherValidatorValidate(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + validator configvalidator.RequiredTogetherValidator + config tfsdk.Config + expected diag.Diagnostics + }{ + "nil-path-expressions": { + validator: configvalidator.RequiredTogetherValidator{ + PathExpressions: nil, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: nil, + }, + "empty-path-expressions": { + validator: configvalidator.RequiredTogetherValidator{ + PathExpressions: path.Expressions{}, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: nil, + }, + "one-non-existent-path-expression": { + validator: configvalidator.RequiredTogetherValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("not-test"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Path Expression for Schema Data", + "The Terraform Provider unexpectedly matched no paths with the given path expression and current schema data. "+ + "This can happen if the path expression does not correctly follow the schema in structure or types. "+ + "Please report this to the provider developers.\n\n"+ + "Path Expression: not-test", + ), + }, + }, + "two-non-existent-path-expression": { + validator: configvalidator.RequiredTogetherValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("not-test1"), + path.MatchRoot("not-test2"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Path Expression for Schema Data", + "The Terraform Provider unexpectedly matched no paths with the given path expression and current schema data. "+ + "This can happen if the path expression does not correctly follow the schema in structure or types. "+ + "Please report this to the provider developers.\n\n"+ + "Path Expression: not-test1", + ), + diag.NewErrorDiagnostic( + "Invalid Path Expression for Schema Data", + "The Terraform Provider unexpectedly matched no paths with the given path expression and current schema data. "+ + "This can happen if the path expression does not correctly follow the schema in structure or types. "+ + "Please report this to the provider developers.\n\n"+ + "Path Expression: not-test2", + ), + }, + }, + "one-matching-path-expression-null": { + validator: configvalidator.RequiredTogetherValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, nil), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: nil, + }, + "one-matching-path-expression-unknown": { + validator: configvalidator.RequiredTogetherValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: nil, + }, + "one-matching-path-expression-value": { + validator: configvalidator.RequiredTogetherValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: nil, + }, + "two-matching-path-expression-one-null-one-value": { + validator: configvalidator.RequiredTogetherValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, nil), + "test2": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test2"), + "Invalid Attribute Combination", + "These attributes must be configured together: [test1,test2]", + ), + }, + }, + "two-matching-path-expression-one-unknown-one-value": { + validator: configvalidator.RequiredTogetherValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test2": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: nil, + }, + "two-matching-path-expression-two-null": { + validator: configvalidator.RequiredTogetherValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, nil), + "test2": tftypes.NewValue(tftypes.String, nil), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: nil, + }, + "two-matching-path-expression-two-unknown": { + validator: configvalidator.RequiredTogetherValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test2": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: nil, + }, + "two-matching-path-expression-two-value": { + validator: configvalidator.RequiredTogetherValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, "test-value"), + "test2": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: nil, + }, + "three-matching-path-expression-two-value-one-null": { + validator: configvalidator.RequiredTogetherValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + path.MatchRoot("test3"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "test3": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "test3": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, "test-value"), + "test2": tftypes.NewValue(tftypes.String, "test-value"), + "test3": tftypes.NewValue(tftypes.String, nil), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test1"), + "Invalid Attribute Combination", + "These attributes must be configured together: [test1,test2,test3]", + ), + }, + }, + "three-matching-path-expression-two-value-one-unknown": { + validator: configvalidator.RequiredTogetherValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + path.MatchRoot("test3"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "test3": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "test3": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, "test-value"), + "test2": tftypes.NewValue(tftypes.String, "test-value"), + "test3": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: nil, + }, + "three-matching-path-expression-three-value": { + validator: configvalidator.RequiredTogetherValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + path.MatchRoot("test3"), + }, + }, + config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "test3": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "test3": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, "test-value"), + "test2": tftypes.NewValue(tftypes.String, "test-value"), + "test3": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expected: nil, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.validator.Validate(context.Background(), testCase.config) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestRequiredTogetherValidatorValidateDataSource(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + validator configvalidator.RequiredTogetherValidator + req datasource.ValidateConfigRequest + expected *datasource.ValidateConfigResponse + }{ + "no-diagnostics": { + validator: configvalidator.RequiredTogetherValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + }, + req: datasource.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &datasource.ValidateConfigResponse{}, + }, + "diagnostics": { + validator: configvalidator.RequiredTogetherValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + }, + req: datasource.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, "test-value"), + "test2": tftypes.NewValue(tftypes.String, nil), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &datasource.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test1"), + "Invalid Attribute Combination", + "These attributes must be configured together: [test1,test2]", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &datasource.ValidateConfigResponse{} + + testCase.validator.ValidateDataSource(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestRequiredTogetherValidatorValidateProvider(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + validator configvalidator.RequiredTogetherValidator + req provider.ValidateConfigRequest + expected *provider.ValidateConfigResponse + }{ + "no-diagnostics": { + validator: configvalidator.RequiredTogetherValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + }, + req: provider.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &provider.ValidateConfigResponse{}, + }, + "diagnostics": { + validator: configvalidator.RequiredTogetherValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + }, + req: provider.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, "test-value"), + "test2": tftypes.NewValue(tftypes.String, nil), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &provider.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test1"), + "Invalid Attribute Combination", + "These attributes must be configured together: [test1,test2]", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &provider.ValidateConfigResponse{} + + testCase.validator.ValidateProvider(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestRequiredTogetherValidatorValidateResource(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + validator configvalidator.RequiredTogetherValidator + req resource.ValidateConfigRequest + expected *resource.ValidateConfigResponse + }{ + "no-diagnostics": { + validator: configvalidator.RequiredTogetherValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + }, + req: resource.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &resource.ValidateConfigResponse{}, + }, + "diagnostics": { + validator: configvalidator.RequiredTogetherValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + }, + req: resource.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, "test-value"), + "test2": tftypes.NewValue(tftypes.String, nil), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &resource.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test1"), + "Invalid Attribute Combination", + "These attributes must be configured together: [test1,test2]", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &resource.ValidateConfigResponse{} + + testCase.validator.ValidateResource(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/providervalidator/at_least_one_of.go b/providervalidator/at_least_one_of.go new file mode 100644 index 00000000..0ba4776a --- /dev/null +++ b/providervalidator/at_least_one_of.go @@ -0,0 +1,15 @@ +package providervalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/configvalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/provider" +) + +// AtLeastOneOf checks that a set of path.Expression has at least one non-null +// or unknown value. +func AtLeastOneOf(expressions ...path.Expression) provider.ConfigValidator { + return &configvalidator.AtLeastOneOfValidator{ + PathExpressions: expressions, + } +} diff --git a/providervalidator/at_least_one_of_example_test.go b/providervalidator/at_least_one_of_example_test.go new file mode 100644 index 00000000..665bbb3d --- /dev/null +++ b/providervalidator/at_least_one_of_example_test.go @@ -0,0 +1,19 @@ +package providervalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/providervalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/provider" +) + +func ExampleAtLeastOneOf() { + // Used inside a provider.Provider type ConfigValidators method + _ = []provider.ConfigValidator{ + // Validate at least one of the schema defined attributes named attr1 + // and attr2 has a known, non-null value. + providervalidator.AtLeastOneOf( + path.MatchRoot("attr1"), + path.MatchRoot("attr2"), + ), + } +} diff --git a/providervalidator/at_least_one_of_test.go b/providervalidator/at_least_one_of_test.go new file mode 100644 index 00000000..18914ecb --- /dev/null +++ b/providervalidator/at_least_one_of_test.go @@ -0,0 +1,125 @@ +package providervalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework-validators/providervalidator" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestAtLeastOneOf(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + pathExpressions path.Expressions + req provider.ValidateConfigRequest + expected *provider.ValidateConfigResponse + }{ + "no-diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + req: provider.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &provider.ValidateConfigResponse{}, + }, + "diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + req: provider.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, nil), + "test2": tftypes.NewValue(tftypes.String, nil), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &provider.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Missing Attribute Configuration", + "At least one of these attributes must be configured: [test1,test2]", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + validator := providervalidator.AtLeastOneOf(testCase.pathExpressions...) + got := &provider.ValidateConfigResponse{} + + validator.ValidateProvider(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/providervalidator/conflicting.go b/providervalidator/conflicting.go new file mode 100644 index 00000000..77f4b9bf --- /dev/null +++ b/providervalidator/conflicting.go @@ -0,0 +1,15 @@ +package providervalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/configvalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/provider" +) + +// Conflicting checks that a set of path.Expression, are not configured +// simultaneously. +func Conflicting(expressions ...path.Expression) provider.ConfigValidator { + return &configvalidator.ConflictingValidator{ + PathExpressions: expressions, + } +} diff --git a/providervalidator/conflicting_example_test.go b/providervalidator/conflicting_example_test.go new file mode 100644 index 00000000..f2d4bd2e --- /dev/null +++ b/providervalidator/conflicting_example_test.go @@ -0,0 +1,19 @@ +package providervalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/providervalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/provider" +) + +func ExampleConflicting() { + // Used inside a provider.Provider type ConfigValidators method + _ = []provider.ConfigValidator{ + // Validate that schema defined attributes named attr1 and attr2 are not + // both configured with known, non-null values. + providervalidator.Conflicting( + path.MatchRoot("attr1"), + path.MatchRoot("attr2"), + ), + } +} diff --git a/providervalidator/conflicting_test.go b/providervalidator/conflicting_test.go new file mode 100644 index 00000000..9a30098e --- /dev/null +++ b/providervalidator/conflicting_test.go @@ -0,0 +1,126 @@ +package providervalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework-validators/providervalidator" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestConflicting(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + pathExpressions path.Expressions + req provider.ValidateConfigRequest + expected *provider.ValidateConfigResponse + }{ + "no-diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + req: provider.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &provider.ValidateConfigResponse{}, + }, + "diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + req: provider.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, "test-value"), + "test2": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &provider.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test1"), + "Invalid Attribute Combination", + "These attributes cannot be configured together: [test1,test2]", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + validator := providervalidator.Conflicting(testCase.pathExpressions...) + got := &provider.ValidateConfigResponse{} + + validator.ValidateProvider(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/providervalidator/doc.go b/providervalidator/doc.go new file mode 100644 index 00000000..edb34f31 --- /dev/null +++ b/providervalidator/doc.go @@ -0,0 +1,10 @@ +// Package providervalidator provides validators to express relationships +// between multiple attributes of a provider. For example, checking that +// multiple attributes are not configured at the same time. +// +// These validators are implemented outside the schema, which may be easier to +// implement in provider code generation situations or suit provider code +// preferences differently than those in the schemavalidator package. Those +// validators start on a starting attribute, where relationships can be +// expressed as absolute paths to others or relative to the starting attribute. +package providervalidator diff --git a/providervalidator/exactly_one_of.go b/providervalidator/exactly_one_of.go new file mode 100644 index 00000000..b6f0d4ea --- /dev/null +++ b/providervalidator/exactly_one_of.go @@ -0,0 +1,15 @@ +package providervalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/configvalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/provider" +) + +// ExactlyOneOf checks that a set of path.Expression does not have more than +// one known value. +func ExactlyOneOf(expressions ...path.Expression) provider.ConfigValidator { + return &configvalidator.ExactlyOneOfValidator{ + PathExpressions: expressions, + } +} diff --git a/providervalidator/exactly_one_of_example_test.go b/providervalidator/exactly_one_of_example_test.go new file mode 100644 index 00000000..8fc3e71a --- /dev/null +++ b/providervalidator/exactly_one_of_example_test.go @@ -0,0 +1,19 @@ +package providervalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/providervalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/provider" +) + +func ExampleExactlyOneOf() { + // Used inside a provider.Provider type ConfigValidators method + _ = []provider.ConfigValidator{ + // Validate only one of the schema defined attributes named attr1 + // and attr2 has a known, non-null value. + providervalidator.ExactlyOneOf( + path.MatchRoot("attr1"), + path.MatchRoot("attr2"), + ), + } +} diff --git a/providervalidator/exactly_one_of_test.go b/providervalidator/exactly_one_of_test.go new file mode 100644 index 00000000..e52eae86 --- /dev/null +++ b/providervalidator/exactly_one_of_test.go @@ -0,0 +1,126 @@ +package providervalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework-validators/providervalidator" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestExactlyOneOf(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + pathExpressions path.Expressions + req provider.ValidateConfigRequest + expected *provider.ValidateConfigResponse + }{ + "no-diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + req: provider.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &provider.ValidateConfigResponse{}, + }, + "diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + req: provider.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, "test-value"), + "test2": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &provider.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test1"), + "Invalid Attribute Combination", + "Exactly one of these attributes must be configured: [test1,test2]", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + validator := providervalidator.ExactlyOneOf(testCase.pathExpressions...) + got := &provider.ValidateConfigResponse{} + + validator.ValidateProvider(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/providervalidator/required_together.go b/providervalidator/required_together.go new file mode 100644 index 00000000..6efb5acd --- /dev/null +++ b/providervalidator/required_together.go @@ -0,0 +1,15 @@ +package providervalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/configvalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/provider" +) + +// RequiredTogether checks that a set of path.Expression either has all known +// or all null values. +func RequiredTogether(expressions ...path.Expression) provider.ConfigValidator { + return &configvalidator.RequiredTogetherValidator{ + PathExpressions: expressions, + } +} diff --git a/providervalidator/required_together_example_test.go b/providervalidator/required_together_example_test.go new file mode 100644 index 00000000..7497805e --- /dev/null +++ b/providervalidator/required_together_example_test.go @@ -0,0 +1,19 @@ +package providervalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/providervalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/provider" +) + +func ExampleRequiredTogether() { + // Used inside a provider.Provider type ConfigValidators method + _ = []provider.ConfigValidator{ + // Validate the schema defined attributes named attr1 and attr2 are either + // both null or both known values. + providervalidator.RequiredTogether( + path.MatchRoot("attr1"), + path.MatchRoot("attr2"), + ), + } +} diff --git a/providervalidator/required_together_test.go b/providervalidator/required_together_test.go new file mode 100644 index 00000000..1bd5774e --- /dev/null +++ b/providervalidator/required_together_test.go @@ -0,0 +1,126 @@ +package providervalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework-validators/providervalidator" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestRequiredTogether(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + pathExpressions path.Expressions + req provider.ValidateConfigRequest + expected *provider.ValidateConfigResponse + }{ + "no-diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + req: provider.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &provider.ValidateConfigResponse{}, + }, + "diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + req: provider.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, "test-value"), + "test2": tftypes.NewValue(tftypes.String, nil), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &provider.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test1"), + "Invalid Attribute Combination", + "These attributes must be configured together: [test1,test2]", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + validator := providervalidator.RequiredTogether(testCase.pathExpressions...) + got := &provider.ValidateConfigResponse{} + + validator.ValidateProvider(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resourcevalidator/at_least_one_of.go b/resourcevalidator/at_least_one_of.go new file mode 100644 index 00000000..11cba7e8 --- /dev/null +++ b/resourcevalidator/at_least_one_of.go @@ -0,0 +1,15 @@ +package resourcevalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/configvalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +// AtLeastOneOf checks that a set of path.Expression has at least one non-null +// or unknown value. +func AtLeastOneOf(expressions ...path.Expression) resource.ConfigValidator { + return &configvalidator.AtLeastOneOfValidator{ + PathExpressions: expressions, + } +} diff --git a/resourcevalidator/at_least_one_of_example_test.go b/resourcevalidator/at_least_one_of_example_test.go new file mode 100644 index 00000000..1993a3f4 --- /dev/null +++ b/resourcevalidator/at_least_one_of_example_test.go @@ -0,0 +1,19 @@ +package resourcevalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/resourcevalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func ExampleAtLeastOneOf() { + // Used inside a resource.Resource type ConfigValidators method + _ = []resource.ConfigValidator{ + // Validate at least one of the schema defined attributes named attr1 + // and attr2 has a known, non-null value. + resourcevalidator.AtLeastOneOf( + path.MatchRoot("attr1"), + path.MatchRoot("attr2"), + ), + } +} diff --git a/resourcevalidator/at_least_one_of_test.go b/resourcevalidator/at_least_one_of_test.go new file mode 100644 index 00000000..2d0762ab --- /dev/null +++ b/resourcevalidator/at_least_one_of_test.go @@ -0,0 +1,125 @@ +package resourcevalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework-validators/resourcevalidator" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestAtLeastOneOf(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + pathExpressions path.Expressions + req resource.ValidateConfigRequest + expected *resource.ValidateConfigResponse + }{ + "no-diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + req: resource.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &resource.ValidateConfigResponse{}, + }, + "diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + req: resource.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, nil), + "test2": tftypes.NewValue(tftypes.String, nil), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &resource.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Missing Attribute Configuration", + "At least one of these attributes must be configured: [test1,test2]", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + validator := resourcevalidator.AtLeastOneOf(testCase.pathExpressions...) + got := &resource.ValidateConfigResponse{} + + validator.ValidateResource(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resourcevalidator/conflicting.go b/resourcevalidator/conflicting.go new file mode 100644 index 00000000..25f88aee --- /dev/null +++ b/resourcevalidator/conflicting.go @@ -0,0 +1,15 @@ +package resourcevalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/configvalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +// Conflicting checks that a set of path.Expression, are not configured +// simultaneously. +func Conflicting(expressions ...path.Expression) resource.ConfigValidator { + return &configvalidator.ConflictingValidator{ + PathExpressions: expressions, + } +} diff --git a/resourcevalidator/conflicting_example_test.go b/resourcevalidator/conflicting_example_test.go new file mode 100644 index 00000000..51b77840 --- /dev/null +++ b/resourcevalidator/conflicting_example_test.go @@ -0,0 +1,19 @@ +package resourcevalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/resourcevalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func ExampleConflicting() { + // Used inside a resource.Resource type ConfigValidators method + _ = []resource.ConfigValidator{ + // Validate that schema defined attributes named attr1 and attr2 are not + // both configured with known, non-null values. + resourcevalidator.Conflicting( + path.MatchRoot("attr1"), + path.MatchRoot("attr2"), + ), + } +} diff --git a/resourcevalidator/conflicting_test.go b/resourcevalidator/conflicting_test.go new file mode 100644 index 00000000..ca4fa5ca --- /dev/null +++ b/resourcevalidator/conflicting_test.go @@ -0,0 +1,126 @@ +package resourcevalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework-validators/resourcevalidator" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestConflicting(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + pathExpressions path.Expressions + req resource.ValidateConfigRequest + expected *resource.ValidateConfigResponse + }{ + "no-diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + req: resource.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &resource.ValidateConfigResponse{}, + }, + "diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + req: resource.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, "test-value"), + "test2": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &resource.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test1"), + "Invalid Attribute Combination", + "These attributes cannot be configured together: [test1,test2]", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + validator := resourcevalidator.Conflicting(testCase.pathExpressions...) + got := &resource.ValidateConfigResponse{} + + validator.ValidateResource(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resourcevalidator/doc.go b/resourcevalidator/doc.go new file mode 100644 index 00000000..ed6f16a1 --- /dev/null +++ b/resourcevalidator/doc.go @@ -0,0 +1,10 @@ +// Package resourcevalidator provides validators to express relationships +// between multiple attributes of a resource. For example, checking that +// multiple attributes are not configured at the same time. +// +// These validators are implemented outside the schema, which may be easier to +// implement in provider code generation situations or suit provider code +// preferences differently than those in the schemavalidator package. Those +// validators start on a starting attribute, where relationships can be +// expressed as absolute paths to others or relative to the starting attribute. +package resourcevalidator diff --git a/resourcevalidator/exactly_one_of.go b/resourcevalidator/exactly_one_of.go new file mode 100644 index 00000000..a1fbdbee --- /dev/null +++ b/resourcevalidator/exactly_one_of.go @@ -0,0 +1,15 @@ +package resourcevalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/configvalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +// ExactlyOneOf checks that a set of path.Expression does not have more than +// one known value. +func ExactlyOneOf(expressions ...path.Expression) resource.ConfigValidator { + return &configvalidator.ExactlyOneOfValidator{ + PathExpressions: expressions, + } +} diff --git a/resourcevalidator/exactly_one_of_example_test.go b/resourcevalidator/exactly_one_of_example_test.go new file mode 100644 index 00000000..f220743e --- /dev/null +++ b/resourcevalidator/exactly_one_of_example_test.go @@ -0,0 +1,19 @@ +package resourcevalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/resourcevalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func ExampleExactlyOneOf() { + // Used inside a resource.Resource type ConfigValidators method + _ = []resource.ConfigValidator{ + // Validate only one of the schema defined attributes named attr1 + // and attr2 has a known, non-null value. + resourcevalidator.ExactlyOneOf( + path.MatchRoot("attr1"), + path.MatchRoot("attr2"), + ), + } +} diff --git a/resourcevalidator/exactly_one_of_test.go b/resourcevalidator/exactly_one_of_test.go new file mode 100644 index 00000000..f39eb453 --- /dev/null +++ b/resourcevalidator/exactly_one_of_test.go @@ -0,0 +1,126 @@ +package resourcevalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework-validators/resourcevalidator" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestExactlyOneOf(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + pathExpressions path.Expressions + req resource.ValidateConfigRequest + expected *resource.ValidateConfigResponse + }{ + "no-diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + req: resource.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &resource.ValidateConfigResponse{}, + }, + "diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + req: resource.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, "test-value"), + "test2": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &resource.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test1"), + "Invalid Attribute Combination", + "Exactly one of these attributes must be configured: [test1,test2]", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + validator := resourcevalidator.ExactlyOneOf(testCase.pathExpressions...) + got := &resource.ValidateConfigResponse{} + + validator.ValidateResource(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resourcevalidator/required_together.go b/resourcevalidator/required_together.go new file mode 100644 index 00000000..787a38be --- /dev/null +++ b/resourcevalidator/required_together.go @@ -0,0 +1,15 @@ +package resourcevalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/configvalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +// RequiredTogether checks that a set of path.Expression either has all known +// or all null values. +func RequiredTogether(expressions ...path.Expression) resource.ConfigValidator { + return &configvalidator.RequiredTogetherValidator{ + PathExpressions: expressions, + } +} diff --git a/resourcevalidator/required_together_example_test.go b/resourcevalidator/required_together_example_test.go new file mode 100644 index 00000000..ac65b26d --- /dev/null +++ b/resourcevalidator/required_together_example_test.go @@ -0,0 +1,19 @@ +package resourcevalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/resourcevalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func ExampleRequiredTogether() { + // Used inside a resource.Resource type ConfigValidators method + _ = []resource.ConfigValidator{ + // Validate the schema defined attributes named attr1 and attr2 are either + // both null or both known values. + resourcevalidator.RequiredTogether( + path.MatchRoot("attr1"), + path.MatchRoot("attr2"), + ), + } +} diff --git a/resourcevalidator/required_together_test.go b/resourcevalidator/required_together_test.go new file mode 100644 index 00000000..bbfbffbc --- /dev/null +++ b/resourcevalidator/required_together_test.go @@ -0,0 +1,126 @@ +package resourcevalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework-validators/resourcevalidator" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestRequiredTogether(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + pathExpressions path.Expressions + req resource.ValidateConfigRequest + expected *resource.ValidateConfigResponse + }{ + "no-diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + req: resource.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &resource.ValidateConfigResponse{}, + }, + "diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + req: resource.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test1": { + Optional: true, + Type: types.StringType, + }, + "test2": { + Optional: true, + Type: types.StringType, + }, + "other": { + Optional: true, + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, "test-value"), + "test2": tftypes.NewValue(tftypes.String, nil), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &resource.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test1"), + "Invalid Attribute Combination", + "These attributes must be configured together: [test1,test2]", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + validator := resourcevalidator.RequiredTogether(testCase.pathExpressions...) + got := &resource.ValidateConfigResponse{} + + validator.ValidateResource(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/schemavalidator/also_requires.go b/schemavalidator/also_requires.go index 92452fce..deff2f07 100644 --- a/schemavalidator/also_requires.go +++ b/schemavalidator/also_requires.go @@ -19,6 +19,9 @@ type alsoRequiresAttributeValidator struct { // if the current attribute also has a non-null value. // // This implements the validation logic declaratively within the tfsdk.Schema. +// Refer to [datasourcevalidator.RequiredTogether], +// [providervalidator.RequiredTogether], or [resourcevalidator.RequiredTogether] +// for declaring this type of validation outside the schema definition. // // Relative path.Expression will be resolved against the validated attribute. func AlsoRequires(attributePaths ...path.Expression) tfsdk.AttributeValidator { diff --git a/schemavalidator/at_least_one_of.go b/schemavalidator/at_least_one_of.go index c87aa981..54539ad3 100644 --- a/schemavalidator/at_least_one_of.go +++ b/schemavalidator/at_least_one_of.go @@ -20,6 +20,9 @@ type atLeastOneOfAttributeValidator struct { // at least one attribute out of all specified is has a non-null value. // // This implements the validation logic declaratively within the tfsdk.Schema. +// Refer to [datasourcevalidator.AtLeastOneOf], +// [providervalidator.AtLeastOneOf], or [resourcevalidator.AtLeastOneOf] +// for declaring this type of validation outside the schema definition. // // Any relative path.Expression will be resolved against the attribute with this validator. func AtLeastOneOf(attributePaths ...path.Expression) tfsdk.AttributeValidator { diff --git a/schemavalidator/conflicts_with.go b/schemavalidator/conflicts_with.go index 306b5d58..157b6e60 100644 --- a/schemavalidator/conflicts_with.go +++ b/schemavalidator/conflicts_with.go @@ -19,6 +19,9 @@ type conflictsWithAttributeValidator struct { // including the attribute it's applied to, do not have a value simultaneously. // // This implements the validation logic declaratively within the tfsdk.Schema. +// Refer to [datasourcevalidator.Conflicting], +// [providervalidator.Conflicting], or [resourcevalidator.Conflicting] +// for declaring this type of validation outside the schema definition. // // Relative path.Expression will be resolved against the validated attribute. func ConflictsWith(attributePaths ...path.Expression) tfsdk.AttributeValidator { diff --git a/schemavalidator/doc.go b/schemavalidator/doc.go index ddd097b8..31c085b0 100644 --- a/schemavalidator/doc.go +++ b/schemavalidator/doc.go @@ -1,4 +1,11 @@ // Package schemavalidator provides validators to express relationships between // multiple attributes within the schema of a resource, data source, or provider. // For example, checking that an attribute is present when another is present, or vice-versa. +// +// These validators are implemented on a starting attribute, where +// relationships can be expressed as absolute paths to others or relative to +// the starting attribute. For multiple attribute validators that are defined +// outside the schema, which may be easier to implement in provider code +// generation situations or suit provider code preferences differently, refer +// to the datasourcevalidator, providervalidator, or resourcevalidator package. package schemavalidator diff --git a/schemavalidator/exactly_one_of.go b/schemavalidator/exactly_one_of.go index 8b098bcb..9d3a7c59 100644 --- a/schemavalidator/exactly_one_of.go +++ b/schemavalidator/exactly_one_of.go @@ -21,6 +21,9 @@ type exactlyOneOfAttributeValidator struct { // It will also cause a validation error if none are specified. // // This implements the validation logic declaratively within the tfsdk.Schema. +// Refer to [datasourcevalidator.ExactlyOneOf], +// [providervalidator.ExactlyOneOf], or [resourcevalidator.ExactlyOneOf] +// for declaring this type of validation outside the schema definition. // // Relative path.Expression will be resolved against the validated attribute. func ExactlyOneOf(attributePaths ...path.Expression) tfsdk.AttributeValidator {