diff --git a/.changes/unreleased/FEATURES-20250815-103910.yaml b/.changes/unreleased/FEATURES-20250815-103910.yaml new file mode 100644 index 00000000..db97d593 --- /dev/null +++ b/.changes/unreleased/FEATURES-20250815-103910.yaml @@ -0,0 +1,5 @@ +kind: FEATURES +body: 'actionvalidator: Introduce new package with declarative validators for action configurations' +time: 2025-08-15T10:39:10.182419-04:00 +custom: + Issue: "302" diff --git a/.changes/unreleased/FEATURES-20250815-103917.yaml b/.changes/unreleased/FEATURES-20250815-103917.yaml new file mode 100644 index 00000000..190a3c46 --- /dev/null +++ b/.changes/unreleased/FEATURES-20250815-103917.yaml @@ -0,0 +1,6 @@ +kind: FEATURES +body: 'listresourcevalidator: Introduce new package with declarative validators for list + resource configurations' +time: 2025-08-15T10:39:17.521201-04:00 +custom: + Issue: "298" diff --git a/actionvalidator/all.go b/actionvalidator/all.go new file mode 100644 index 00000000..6a4de6d1 --- /dev/null +++ b/actionvalidator/all.go @@ -0,0 +1,57 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package actionvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/action" +) + +// All returns a validator which ensures that any configured attribute value +// validates against all the given validators. +// +// Use of All is only necessary when used in conjunction with Any or AnyWithAllWarnings +// as the Validators field automatically applies a logical AND. +func All(validators ...action.ConfigValidator) action.ConfigValidator { + return allValidator{ + validators: validators, + } +} + +var _ action.ConfigValidator = allValidator{} + +// allValidator implements the validator. +type allValidator struct { + validators []action.ConfigValidator +} + +// Description describes the validation in plain text formatting. +func (v allValidator) Description(ctx context.Context) string { + var descriptions []string + + for _, subValidator := range v.validators { + descriptions = append(descriptions, subValidator.Description(ctx)) + } + + return fmt.Sprintf("Value must satisfy all of the validations: %s", strings.Join(descriptions, " + ")) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (v allValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +// ValidateAction performs the validation. +func (v allValidator) ValidateAction(ctx context.Context, req action.ValidateConfigRequest, resp *action.ValidateConfigResponse) { + for _, subValidator := range v.validators { + validateResp := &action.ValidateConfigResponse{} + + subValidator.ValidateAction(ctx, req, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) + } +} diff --git a/actionvalidator/all_example_test.go b/actionvalidator/all_example_test.go new file mode 100644 index 00000000..fa6f1e27 --- /dev/null +++ b/actionvalidator/all_example_test.go @@ -0,0 +1,21 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package actionvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework/action" + + "github.com/hashicorp/terraform-plugin-framework-validators/actionvalidator" +) + +func ExampleAll() { + // Used inside a action.Action type ConfigValidators method + _ = []action.ConfigValidator{ + // The configuration must satisfy either All validator. + actionvalidator.Any( + actionvalidator.All( /* ... */ ), + actionvalidator.All( /* ... */ ), + ), + } +} diff --git a/actionvalidator/all_test.go b/actionvalidator/all_test.go new file mode 100644 index 00000000..0846c8bb --- /dev/null +++ b/actionvalidator/all_test.go @@ -0,0 +1,177 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package actionvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/action" + "github.com/hashicorp/terraform-plugin-framework/action/schema" + "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-go/tftypes" + + "github.com/hashicorp/terraform-plugin-framework-validators/actionvalidator" +) + +func TestAllValidatorValidateAction(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + validators []action.ConfigValidator + req action.ValidateConfigRequest + expected *action.ValidateConfigResponse + }{ + "no-diagnostics": { + validators: []action.ConfigValidator{ + actionvalidator.ExactlyOneOf( + path.MatchRoot("test1"), + path.MatchRoot("test2"), + ), + actionvalidator.All( + actionvalidator.AtLeastOneOf( + path.MatchRoot("test3"), + path.MatchRoot("test4"), + ), + actionvalidator.Conflicting( + path.MatchRoot("test3"), + path.MatchRoot("test5"), + ), + ), + }, + req: action.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.UnlinkedSchema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ + Optional: true, + }, + "test2": schema.StringAttribute{ + Optional: true, + }, + "test3": schema.StringAttribute{ + Optional: true, + }, + "test4": schema.StringAttribute{ + Optional: true, + }, + "test5": schema.StringAttribute{ + Optional: true, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "test3": tftypes.String, + "test4": tftypes.String, + "test5": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, nil), + "test2": tftypes.NewValue(tftypes.String, nil), + "test3": tftypes.NewValue(tftypes.String, "test-value"), + "test4": tftypes.NewValue(tftypes.String, nil), + "test5": tftypes.NewValue(tftypes.String, nil), + }, + ), + }, + }, + expected: &action.ValidateConfigResponse{}, + }, + "diagnostics": { + validators: []action.ConfigValidator{ + actionvalidator.ExactlyOneOf( + path.MatchRoot("test1"), + path.MatchRoot("test2"), + ), + actionvalidator.All( + actionvalidator.AtLeastOneOf( + path.MatchRoot("test3"), + path.MatchRoot("test4"), + ), + actionvalidator.Conflicting( + path.MatchRoot("test3"), + path.MatchRoot("test5"), + ), + ), + }, + req: action.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.UnlinkedSchema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ + Optional: true, + }, + "test2": schema.StringAttribute{ + Optional: true, + }, + "test3": schema.StringAttribute{ + Optional: true, + }, + "test4": schema.StringAttribute{ + Optional: true, + }, + "test5": schema.StringAttribute{ + Optional: true, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "test3": tftypes.String, + "test4": tftypes.String, + "test5": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, nil), + "test2": tftypes.NewValue(tftypes.String, nil), + "test3": tftypes.NewValue(tftypes.String, "test-value"), + "test4": tftypes.NewValue(tftypes.String, nil), + "test5": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &action.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Missing Attribute Configuration", + "Exactly one of these attributes must be configured: [test1,test2]", + ), + diag.WithPath(path.Root("test3"), + diag.NewErrorDiagnostic( + "Invalid Attribute Combination", + "These attributes cannot be configured together: [test3,test5]", + )), + }, + }, + }, + } + + for name, testCase := range testCases { + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &action.ValidateConfigResponse{} + + actionvalidator.Any(testCase.validators...).ValidateAction(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/actionvalidator/any.go b/actionvalidator/any.go new file mode 100644 index 00000000..b6797f22 --- /dev/null +++ b/actionvalidator/any.go @@ -0,0 +1,65 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package actionvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/action" +) + +// Any returns a validator which ensures that any configured attribute value +// passes at least one of the given validators. +// +// To prevent practitioner confusion should non-passing validators have +// conflicting logic, only warnings from the passing validator are returned. +// Use AnyWithAllWarnings() to return warnings from non-passing validators +// as well. +func Any(validators ...action.ConfigValidator) action.ConfigValidator { + return anyValidator{ + validators: validators, + } +} + +var _ action.ConfigValidator = anyValidator{} + +// anyValidator implements the validator. +type anyValidator struct { + validators []action.ConfigValidator +} + +// Description describes the validation in plain text formatting. +func (v anyValidator) Description(ctx context.Context) string { + var descriptions []string + + for _, subValidator := range v.validators { + descriptions = append(descriptions, subValidator.Description(ctx)) + } + + return fmt.Sprintf("Value must satisfy at least one of the validations: %s", strings.Join(descriptions, " + ")) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (v anyValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +// ValidateAction performs the validation. +func (v anyValidator) ValidateAction(ctx context.Context, req action.ValidateConfigRequest, resp *action.ValidateConfigResponse) { + for _, subValidator := range v.validators { + validateResp := &action.ValidateConfigResponse{} + + subValidator.ValidateAction(ctx, req, validateResp) + + if !validateResp.Diagnostics.HasError() { + resp.Diagnostics = validateResp.Diagnostics + + return + } + + resp.Diagnostics.Append(validateResp.Diagnostics...) + } +} diff --git a/actionvalidator/any_example_test.go b/actionvalidator/any_example_test.go new file mode 100644 index 00000000..ab220a29 --- /dev/null +++ b/actionvalidator/any_example_test.go @@ -0,0 +1,16 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package actionvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/actionvalidator" + "github.com/hashicorp/terraform-plugin-framework/action" +) + +func ExampleAny() { + // Used inside a action.Action type ConfigValidators method + _ = []action.ConfigValidator{ + actionvalidator.Any( /* ... */ ), + } +} diff --git a/actionvalidator/any_test.go b/actionvalidator/any_test.go new file mode 100644 index 00000000..0a0c4e04 --- /dev/null +++ b/actionvalidator/any_test.go @@ -0,0 +1,154 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package actionvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/action" + "github.com/hashicorp/terraform-plugin-framework/action/schema" + "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-go/tftypes" + + "github.com/hashicorp/terraform-plugin-framework-validators/actionvalidator" +) + +func TestAnyValidatorValidateAction(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + validators []action.ConfigValidator + req action.ValidateConfigRequest + expected *action.ValidateConfigResponse + }{ + "no-diagnostics": { + validators: []action.ConfigValidator{ + actionvalidator.ExactlyOneOf( + path.MatchRoot("test1"), + path.MatchRoot("test2"), + ), + actionvalidator.ExactlyOneOf( + path.MatchRoot("test3"), + path.MatchRoot("test4"), + ), + }, + req: action.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.UnlinkedSchema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ + Optional: true, + }, + "test2": schema.StringAttribute{ + Optional: true, + }, + "test3": schema.StringAttribute{ + Optional: true, + }, + "test4": schema.StringAttribute{ + Optional: true, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "test3": tftypes.String, + "test4": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, nil), + "test2": tftypes.NewValue(tftypes.String, nil), + "test3": tftypes.NewValue(tftypes.String, "test-value"), + "test4": tftypes.NewValue(tftypes.String, nil), + }, + ), + }, + }, + expected: &action.ValidateConfigResponse{}, + }, + "diagnostics": { + validators: []action.ConfigValidator{ + actionvalidator.ExactlyOneOf( + path.MatchRoot("test1"), + path.MatchRoot("test2"), + ), + actionvalidator.ExactlyOneOf( + path.MatchRoot("test3"), + path.MatchRoot("test4"), + ), + }, + req: action.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.UnlinkedSchema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ + Optional: true, + }, + "test2": schema.StringAttribute{ + Optional: true, + }, + "test3": schema.StringAttribute{ + Optional: true, + }, + "test4": schema.StringAttribute{ + Optional: true, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "test3": tftypes.String, + "test4": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, nil), + "test2": tftypes.NewValue(tftypes.String, nil), + "test3": tftypes.NewValue(tftypes.String, nil), + "test4": tftypes.NewValue(tftypes.String, nil), + }, + ), + }, + }, + expected: &action.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Missing Attribute Configuration", + "Exactly one of these attributes must be configured: [test1,test2]", + ), + diag.NewErrorDiagnostic( + "Missing Attribute Configuration", + "Exactly one of these attributes must be configured: [test3,test4]", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &action.ValidateConfigResponse{} + + actionvalidator.Any(testCase.validators...).ValidateAction(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/actionvalidator/any_with_all_warnings.go b/actionvalidator/any_with_all_warnings.go new file mode 100644 index 00000000..1c5be458 --- /dev/null +++ b/actionvalidator/any_with_all_warnings.go @@ -0,0 +1,67 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package actionvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/action" +) + +// AnyWithAllWarnings returns a validator which ensures that any configured +// attribute value passes at least one of the given validators. This validator +// returns all warnings, including failed validators. +// +// Use Any() to return warnings only from the passing validator. +func AnyWithAllWarnings(validators ...action.ConfigValidator) action.ConfigValidator { + return anyWithAllWarningsValidator{ + validators: validators, + } +} + +var _ action.ConfigValidator = anyWithAllWarningsValidator{} + +// anyWithAllWarningsValidator implements the validator. +type anyWithAllWarningsValidator struct { + validators []action.ConfigValidator +} + +// Description describes the validation in plain text formatting. +func (v anyWithAllWarningsValidator) Description(ctx context.Context) string { + var descriptions []string + + for _, subValidator := range v.validators { + descriptions = append(descriptions, subValidator.Description(ctx)) + } + + return fmt.Sprintf("Value must satisfy at least one of the validations: %s", strings.Join(descriptions, " + ")) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (v anyWithAllWarningsValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +// ValidateAction performs the validation. +func (v anyWithAllWarningsValidator) ValidateAction(ctx context.Context, req action.ValidateConfigRequest, resp *action.ValidateConfigResponse) { + anyValid := false + + for _, subValidator := range v.validators { + validateResp := &action.ValidateConfigResponse{} + + subValidator.ValidateAction(ctx, req, validateResp) + + if !validateResp.Diagnostics.HasError() { + anyValid = true + } + + resp.Diagnostics.Append(validateResp.Diagnostics...) + } + + if anyValid { + resp.Diagnostics = resp.Diagnostics.Warnings() + } +} diff --git a/actionvalidator/any_with_all_warnings_example_test.go b/actionvalidator/any_with_all_warnings_example_test.go new file mode 100644 index 00000000..5866f3a8 --- /dev/null +++ b/actionvalidator/any_with_all_warnings_example_test.go @@ -0,0 +1,16 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package actionvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/actionvalidator" + "github.com/hashicorp/terraform-plugin-framework/action" +) + +func ExampleAnyWithAllWarnings() { + // Used inside a action.Action type ConfigValidators method + _ = []action.ConfigValidator{ + actionvalidator.AnyWithAllWarnings( /* ... */ ), + } +} diff --git a/actionvalidator/any_with_all_warnings_test.go b/actionvalidator/any_with_all_warnings_test.go new file mode 100644 index 00000000..742a6fee --- /dev/null +++ b/actionvalidator/any_with_all_warnings_test.go @@ -0,0 +1,215 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package actionvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/action" + "github.com/hashicorp/terraform-plugin-framework/action/schema" + "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-go/tftypes" + + "github.com/hashicorp/terraform-plugin-framework-validators/actionvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/internal/testvalidator" +) + +func TestAnyWithAllWarningsValidatorValidateAction(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + validators []action.ConfigValidator + req action.ValidateConfigRequest + expected *action.ValidateConfigResponse + }{ + "valid": { + validators: []action.ConfigValidator{ + actionvalidator.ExactlyOneOf( + path.MatchRoot("test1"), + path.MatchRoot("test2"), + ), + actionvalidator.ExactlyOneOf( + path.MatchRoot("test3"), + path.MatchRoot("test4"), + ), + }, + req: action.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.UnlinkedSchema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ + Optional: true, + }, + "test2": schema.StringAttribute{ + Optional: true, + }, + "test3": schema.StringAttribute{ + Optional: true, + }, + "test4": schema.StringAttribute{ + Optional: true, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "test3": tftypes.String, + "test4": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, nil), + "test2": tftypes.NewValue(tftypes.String, nil), + "test3": tftypes.NewValue(tftypes.String, "test-value"), + "test4": tftypes.NewValue(tftypes.String, nil), + }, + ), + }, + }, + expected: &action.ValidateConfigResponse{}, + }, + "valid with warning": { + validators: []action.ConfigValidator{ + actionvalidator.All( + actionvalidator.ExactlyOneOf( + path.MatchRoot("test1"), + path.MatchRoot("test2"), + ), + testvalidator.WarningAction("failing warning summary", "failing warning details"), + ), + actionvalidator.All( + actionvalidator.ExactlyOneOf( + path.MatchRoot("test3"), + path.MatchRoot("test4"), + ), + testvalidator.WarningAction("passing warning summary", "passing warning details"), + ), + }, + req: action.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.UnlinkedSchema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ + Optional: true, + }, + "test2": schema.StringAttribute{ + Optional: true, + }, + "test3": schema.StringAttribute{ + Optional: true, + }, + "test4": schema.StringAttribute{ + Optional: true, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "test3": tftypes.String, + "test4": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, nil), + "test2": tftypes.NewValue(tftypes.String, nil), + "test3": tftypes.NewValue(tftypes.String, "test-value"), + "test4": tftypes.NewValue(tftypes.String, nil), + }, + ), + }, + }, + expected: &action.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewWarningDiagnostic("failing warning summary", "failing warning details"), + diag.NewWarningDiagnostic("passing warning summary", "passing warning details"), + }, + }, + }, + "invalid": { + validators: []action.ConfigValidator{ + actionvalidator.ExactlyOneOf( + path.MatchRoot("test1"), + path.MatchRoot("test2"), + ), + actionvalidator.ExactlyOneOf( + path.MatchRoot("test3"), + path.MatchRoot("test4"), + ), + }, + req: action.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.UnlinkedSchema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ + Optional: true, + }, + "test2": schema.StringAttribute{ + Optional: true, + }, + "test3": schema.StringAttribute{ + Optional: true, + }, + "test4": schema.StringAttribute{ + Optional: true, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "test3": tftypes.String, + "test4": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, nil), + "test2": tftypes.NewValue(tftypes.String, nil), + "test3": tftypes.NewValue(tftypes.String, nil), + "test4": tftypes.NewValue(tftypes.String, nil), + }, + ), + }, + }, + expected: &action.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Missing Attribute Configuration", + "Exactly one of these attributes must be configured: [test1,test2]", + ), + diag.NewErrorDiagnostic( + "Missing Attribute Configuration", + "Exactly one of these attributes must be configured: [test3,test4]", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &action.ValidateConfigResponse{} + + actionvalidator.AnyWithAllWarnings(testCase.validators...).ValidateAction(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/actionvalidator/at_least_one_of.go b/actionvalidator/at_least_one_of.go new file mode 100644 index 00000000..1167a309 --- /dev/null +++ b/actionvalidator/at_least_one_of.go @@ -0,0 +1,18 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package actionvalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/configvalidator" + "github.com/hashicorp/terraform-plugin-framework/action" + "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) action.ConfigValidator { + return &configvalidator.AtLeastOneOfValidator{ + PathExpressions: expressions, + } +} diff --git a/actionvalidator/at_least_one_of_example_test.go b/actionvalidator/at_least_one_of_example_test.go new file mode 100644 index 00000000..89e8d2c7 --- /dev/null +++ b/actionvalidator/at_least_one_of_example_test.go @@ -0,0 +1,22 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package actionvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/actionvalidator" + "github.com/hashicorp/terraform-plugin-framework/action" + "github.com/hashicorp/terraform-plugin-framework/path" +) + +func ExampleAtLeastOneOf() { + // Used inside a action.Action type ConfigValidators method + _ = []action.ConfigValidator{ + // Validate at least one of the schema defined attributes named attr1 + // and attr2 has a known, non-null value. + actionvalidator.AtLeastOneOf( + path.MatchRoot("attr1"), + path.MatchRoot("attr2"), + ), + } +} diff --git a/actionvalidator/at_least_one_of_test.go b/actionvalidator/at_least_one_of_test.go new file mode 100644 index 00000000..928ab726 --- /dev/null +++ b/actionvalidator/at_least_one_of_test.go @@ -0,0 +1,123 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package actionvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/action" + "github.com/hashicorp/terraform-plugin-framework/action/schema" + "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-go/tftypes" + + "github.com/hashicorp/terraform-plugin-framework-validators/actionvalidator" +) + +func TestAtLeastOneOf(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + pathExpressions path.Expressions + req action.ValidateConfigRequest + expected *action.ValidateConfigResponse + }{ + "no-diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + req: action.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.UnlinkedSchema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ + Optional: true, + }, + "other": schema.StringAttribute{ + Optional: true, + }, + }, + }, + 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: &action.ValidateConfigResponse{}, + }, + "diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + req: action.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.UnlinkedSchema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ + Optional: true, + }, + "test2": schema.StringAttribute{ + Optional: true, + }, + "other": schema.StringAttribute{ + Optional: true, + }, + }, + }, + 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: &action.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 { + + t.Run(name, func(t *testing.T) { + t.Parallel() + + validator := actionvalidator.AtLeastOneOf(testCase.pathExpressions...) + got := &action.ValidateConfigResponse{} + + validator.ValidateAction(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/actionvalidator/conflicting.go b/actionvalidator/conflicting.go new file mode 100644 index 00000000..a3035e7a --- /dev/null +++ b/actionvalidator/conflicting.go @@ -0,0 +1,18 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package actionvalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/configvalidator" + "github.com/hashicorp/terraform-plugin-framework/action" + "github.com/hashicorp/terraform-plugin-framework/path" +) + +// Conflicting checks that a set of path.Expression, are not configured +// simultaneously. +func Conflicting(expressions ...path.Expression) action.ConfigValidator { + return &configvalidator.ConflictingValidator{ + PathExpressions: expressions, + } +} diff --git a/actionvalidator/conflicting_example_test.go b/actionvalidator/conflicting_example_test.go new file mode 100644 index 00000000..e70f2d2f --- /dev/null +++ b/actionvalidator/conflicting_example_test.go @@ -0,0 +1,22 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package actionvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/actionvalidator" + "github.com/hashicorp/terraform-plugin-framework/action" + "github.com/hashicorp/terraform-plugin-framework/path" +) + +func ExampleConflicting() { + // Used inside a action.Action type ConfigValidators method + _ = []action.ConfigValidator{ + // Validate that schema defined attributes named attr1 and attr2 are not + // both configured with known, non-null values. + actionvalidator.Conflicting( + path.MatchRoot("attr1"), + path.MatchRoot("attr2"), + ), + } +} diff --git a/actionvalidator/conflicting_test.go b/actionvalidator/conflicting_test.go new file mode 100644 index 00000000..e8ed0f1e --- /dev/null +++ b/actionvalidator/conflicting_test.go @@ -0,0 +1,124 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package actionvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/action" + "github.com/hashicorp/terraform-plugin-framework/action/schema" + "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-go/tftypes" + + "github.com/hashicorp/terraform-plugin-framework-validators/actionvalidator" +) + +func TestConflicting(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + pathExpressions path.Expressions + req action.ValidateConfigRequest + expected *action.ValidateConfigResponse + }{ + "no-diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + req: action.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.UnlinkedSchema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ + Optional: true, + }, + "other": schema.StringAttribute{ + Optional: true, + }, + }, + }, + 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: &action.ValidateConfigResponse{}, + }, + "diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + req: action.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.UnlinkedSchema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ + Optional: true, + }, + "test2": schema.StringAttribute{ + Optional: true, + }, + "other": schema.StringAttribute{ + Optional: true, + }, + }, + }, + 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: &action.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 { + + t.Run(name, func(t *testing.T) { + t.Parallel() + + validator := actionvalidator.Conflicting(testCase.pathExpressions...) + got := &action.ValidateConfigResponse{} + + validator.ValidateAction(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/actionvalidator/doc.go b/actionvalidator/doc.go new file mode 100644 index 00000000..e3f19a9e --- /dev/null +++ b/actionvalidator/doc.go @@ -0,0 +1,13 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +// Package actionvalidator provides validators to express relationships +// between multiple attributes of an action. 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 actionvalidator diff --git a/actionvalidator/exactly_one_of.go b/actionvalidator/exactly_one_of.go new file mode 100644 index 00000000..06747faf --- /dev/null +++ b/actionvalidator/exactly_one_of.go @@ -0,0 +1,18 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package actionvalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/configvalidator" + "github.com/hashicorp/terraform-plugin-framework/action" + "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) action.ConfigValidator { + return &configvalidator.ExactlyOneOfValidator{ + PathExpressions: expressions, + } +} diff --git a/actionvalidator/exactly_one_of_example_test.go b/actionvalidator/exactly_one_of_example_test.go new file mode 100644 index 00000000..6b2b67e7 --- /dev/null +++ b/actionvalidator/exactly_one_of_example_test.go @@ -0,0 +1,22 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package actionvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/actionvalidator" + "github.com/hashicorp/terraform-plugin-framework/action" + "github.com/hashicorp/terraform-plugin-framework/path" +) + +func ExampleExactlyOneOf() { + // Used inside a action.Action type ConfigValidators method + _ = []action.ConfigValidator{ + // Validate only one of the schema defined attributes named attr1 + // and attr2 has a known, non-null value. + actionvalidator.ExactlyOneOf( + path.MatchRoot("attr1"), + path.MatchRoot("attr2"), + ), + } +} diff --git a/actionvalidator/exactly_one_of_test.go b/actionvalidator/exactly_one_of_test.go new file mode 100644 index 00000000..8c2032d1 --- /dev/null +++ b/actionvalidator/exactly_one_of_test.go @@ -0,0 +1,124 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package actionvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/action" + "github.com/hashicorp/terraform-plugin-framework/action/schema" + "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-go/tftypes" + + "github.com/hashicorp/terraform-plugin-framework-validators/actionvalidator" +) + +func TestExactlyOneOf(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + pathExpressions path.Expressions + req action.ValidateConfigRequest + expected *action.ValidateConfigResponse + }{ + "no-diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + req: action.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.UnlinkedSchema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ + Optional: true, + }, + "other": schema.StringAttribute{ + Optional: true, + }, + }, + }, + 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: &action.ValidateConfigResponse{}, + }, + "diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + req: action.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.UnlinkedSchema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ + Optional: true, + }, + "test2": schema.StringAttribute{ + Optional: true, + }, + "other": schema.StringAttribute{ + Optional: true, + }, + }, + }, + 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: &action.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 { + + t.Run(name, func(t *testing.T) { + t.Parallel() + + validator := actionvalidator.ExactlyOneOf(testCase.pathExpressions...) + got := &action.ValidateConfigResponse{} + + validator.ValidateAction(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/actionvalidator/required_together.go b/actionvalidator/required_together.go new file mode 100644 index 00000000..aa5c7182 --- /dev/null +++ b/actionvalidator/required_together.go @@ -0,0 +1,18 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package actionvalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/configvalidator" + "github.com/hashicorp/terraform-plugin-framework/action" + "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) action.ConfigValidator { + return &configvalidator.RequiredTogetherValidator{ + PathExpressions: expressions, + } +} diff --git a/actionvalidator/required_together_example_test.go b/actionvalidator/required_together_example_test.go new file mode 100644 index 00000000..1c793996 --- /dev/null +++ b/actionvalidator/required_together_example_test.go @@ -0,0 +1,22 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package actionvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/actionvalidator" + "github.com/hashicorp/terraform-plugin-framework/action" + "github.com/hashicorp/terraform-plugin-framework/path" +) + +func ExampleRequiredTogether() { + // Used inside a action.Action type ConfigValidators method + _ = []action.ConfigValidator{ + // Validate the schema defined attributes named attr1 and attr2 are either + // both null or both known values. + actionvalidator.RequiredTogether( + path.MatchRoot("attr1"), + path.MatchRoot("attr2"), + ), + } +} diff --git a/actionvalidator/required_together_test.go b/actionvalidator/required_together_test.go new file mode 100644 index 00000000..afd3158c --- /dev/null +++ b/actionvalidator/required_together_test.go @@ -0,0 +1,124 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package actionvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/action" + "github.com/hashicorp/terraform-plugin-framework/action/schema" + "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-go/tftypes" + + "github.com/hashicorp/terraform-plugin-framework-validators/actionvalidator" +) + +func TestRequiredTogether(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + pathExpressions path.Expressions + req action.ValidateConfigRequest + expected *action.ValidateConfigResponse + }{ + "no-diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + req: action.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.UnlinkedSchema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ + Optional: true, + }, + "other": schema.StringAttribute{ + Optional: true, + }, + }, + }, + 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: &action.ValidateConfigResponse{}, + }, + "diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + req: action.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.UnlinkedSchema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ + Optional: true, + }, + "test2": schema.StringAttribute{ + Optional: true, + }, + "other": schema.StringAttribute{ + Optional: true, + }, + }, + }, + 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: &action.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 { + + t.Run(name, func(t *testing.T) { + t.Parallel() + + validator := actionvalidator.RequiredTogether(testCase.pathExpressions...) + got := &action.ValidateConfigResponse{} + + validator.ValidateAction(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 index c4b9c82c..942b573e 100644 --- a/internal/configvalidator/at_least_one_of.go +++ b/internal/configvalidator/at_least_one_of.go @@ -7,6 +7,7 @@ import ( "context" "fmt" + "github.com/hashicorp/terraform-plugin-framework/action" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/diag" @@ -35,6 +36,10 @@ 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) ValidateAction(ctx context.Context, req action.ValidateConfigRequest, resp *action.ValidateConfigResponse) { + resp.Diagnostics = v.Validate(ctx, req.Config) +} + func (v AtLeastOneOfValidator) ValidateDataSource(ctx context.Context, req datasource.ValidateConfigRequest, resp *datasource.ValidateConfigResponse) { resp.Diagnostics = v.Validate(ctx, req.Config) } diff --git a/internal/configvalidator/at_least_one_of_test.go b/internal/configvalidator/at_least_one_of_test.go index 276ebb5a..429d7fa6 100644 --- a/internal/configvalidator/at_least_one_of_test.go +++ b/internal/configvalidator/at_least_one_of_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/action" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/ephemeral" @@ -1038,3 +1039,110 @@ func TestAtLeastOneOfValidatorValidateEphemeralResource(t *testing.T) { }) } } + +func TestAtLeastOneOfValidatorValidateAction(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + validator configvalidator.AtLeastOneOfValidator + req action.ValidateConfigRequest + expected *action.ValidateConfigResponse + }{ + "no-diagnostics": { + validator: configvalidator.AtLeastOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + }, + req: action.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ + Optional: true, + }, + "other": schema.StringAttribute{ + Optional: true, + }, + }, + }, + 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: &action.ValidateConfigResponse{}, + }, + "diagnostics": { + validator: configvalidator.AtLeastOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + }, + req: action.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ + Optional: true, + }, + "test2": schema.StringAttribute{ + Optional: true, + }, + "other": schema.StringAttribute{ + Optional: true, + }, + }, + }, + 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: &action.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 { + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &action.ValidateConfigResponse{} + + testCase.validator.ValidateAction(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 index bd867e96..0dafca32 100644 --- a/internal/configvalidator/conflicting.go +++ b/internal/configvalidator/conflicting.go @@ -8,6 +8,7 @@ import ( "fmt" "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" + "github.com/hashicorp/terraform-plugin-framework/action" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/diag" @@ -36,6 +37,10 @@ func (v ConflictingValidator) MarkdownDescription(_ context.Context) string { return fmt.Sprintf("These attributes cannot be configured together: %s", v.PathExpressions) } +func (v ConflictingValidator) ValidateAction(ctx context.Context, req action.ValidateConfigRequest, resp *action.ValidateConfigResponse) { + resp.Diagnostics = v.Validate(ctx, req.Config) +} + func (v ConflictingValidator) ValidateDataSource(ctx context.Context, req datasource.ValidateConfigRequest, resp *datasource.ValidateConfigResponse) { resp.Diagnostics = v.Validate(ctx, req.Config) } diff --git a/internal/configvalidator/conflicting_test.go b/internal/configvalidator/conflicting_test.go index 20f973a9..ad539d15 100644 --- a/internal/configvalidator/conflicting_test.go +++ b/internal/configvalidator/conflicting_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/action" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/ephemeral" @@ -1046,3 +1047,111 @@ func TestConflictingValidatorValidateEphemeralResource(t *testing.T) { }) } } + +func TestConflictingValidatorValidateAction(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + validator configvalidator.ConflictingValidator + req action.ValidateConfigRequest + expected *action.ValidateConfigResponse + }{ + "no-diagnostics": { + validator: configvalidator.ConflictingValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + }, + req: action.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ + Optional: true, + }, + "other": schema.StringAttribute{ + Optional: true, + }, + }, + }, + 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: &action.ValidateConfigResponse{}, + }, + "diagnostics": { + validator: configvalidator.ConflictingValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + }, + req: action.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ + Optional: true, + }, + "test2": schema.StringAttribute{ + Optional: true, + }, + "other": schema.StringAttribute{ + Optional: true, + }, + }, + }, + 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: &action.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 { + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &action.ValidateConfigResponse{} + + testCase.validator.ValidateAction(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 index b3533ae2..c39a56ee 100644 --- a/internal/configvalidator/doc.go +++ b/internal/configvalidator/doc.go @@ -2,6 +2,11 @@ // SPDX-License-Identifier: MPL-2.0 // Package configvalidator provides the generic configuration validator -// implementations for the exported datasourcevalidator, providervalidator, -// resourcevalidator, and ephemeralvalidator packages. +// implementations for the exported packages: +// - actionvalidator +// - datasourcevalidator +// - ephemeralvalidator +// - listresourcevalidator +// - providervalidator +// - resourcevalidator package configvalidator diff --git a/internal/configvalidator/exactly_one_of.go b/internal/configvalidator/exactly_one_of.go index b0b2f011..2aca8e35 100644 --- a/internal/configvalidator/exactly_one_of.go +++ b/internal/configvalidator/exactly_one_of.go @@ -8,6 +8,7 @@ import ( "fmt" "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" + "github.com/hashicorp/terraform-plugin-framework/action" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/diag" @@ -36,6 +37,10 @@ func (v ExactlyOneOfValidator) MarkdownDescription(_ context.Context) string { return fmt.Sprintf("Exactly one of these attributes must be configured: %s", v.PathExpressions) } +func (v ExactlyOneOfValidator) ValidateAction(ctx context.Context, req action.ValidateConfigRequest, resp *action.ValidateConfigResponse) { + resp.Diagnostics = v.Validate(ctx, req.Config) +} + func (v ExactlyOneOfValidator) ValidateDataSource(ctx context.Context, req datasource.ValidateConfigRequest, resp *datasource.ValidateConfigResponse) { resp.Diagnostics = v.Validate(ctx, req.Config) } diff --git a/internal/configvalidator/exactly_one_of_test.go b/internal/configvalidator/exactly_one_of_test.go index ae8f3de7..b319680a 100644 --- a/internal/configvalidator/exactly_one_of_test.go +++ b/internal/configvalidator/exactly_one_of_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/action" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/ephemeral" @@ -1066,3 +1067,111 @@ func TestExactlyOneOfValidatorValidateEphemeralResource(t *testing.T) { }) } } + +func TestExactlyOneOfValidatorValidateAction(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + validator configvalidator.ExactlyOneOfValidator + req action.ValidateConfigRequest + expected *action.ValidateConfigResponse + }{ + "no-diagnostics": { + validator: configvalidator.ExactlyOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + }, + req: action.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ + Optional: true, + }, + "other": schema.StringAttribute{ + Optional: true, + }, + }, + }, + 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: &action.ValidateConfigResponse{}, + }, + "diagnostics": { + validator: configvalidator.ExactlyOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + }, + req: action.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ + Optional: true, + }, + "test2": schema.StringAttribute{ + Optional: true, + }, + "other": schema.StringAttribute{ + Optional: true, + }, + }, + }, + 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: &action.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 { + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &action.ValidateConfigResponse{} + + testCase.validator.ValidateAction(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 index f5159d67..5cd6a4ab 100644 --- a/internal/configvalidator/required_together.go +++ b/internal/configvalidator/required_together.go @@ -8,6 +8,7 @@ import ( "fmt" "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" + "github.com/hashicorp/terraform-plugin-framework/action" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/diag" @@ -36,6 +37,10 @@ func (v RequiredTogetherValidator) MarkdownDescription(_ context.Context) string return fmt.Sprintf("These attributes must be configured together: %s", v.PathExpressions) } +func (v RequiredTogetherValidator) ValidateAction(ctx context.Context, req action.ValidateConfigRequest, resp *action.ValidateConfigResponse) { + resp.Diagnostics = v.Validate(ctx, req.Config) +} + func (v RequiredTogetherValidator) ValidateDataSource(ctx context.Context, req datasource.ValidateConfigRequest, resp *datasource.ValidateConfigResponse) { resp.Diagnostics = v.Validate(ctx, req.Config) } diff --git a/internal/configvalidator/required_together_test.go b/internal/configvalidator/required_together_test.go index 595d83f9..5384b340 100644 --- a/internal/configvalidator/required_together_test.go +++ b/internal/configvalidator/required_together_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/action" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/ephemeral" @@ -1034,3 +1035,111 @@ func TestRequiredTogetherValidatorValidateEphemeralResource(t *testing.T) { }) } } + +func TestRequiredTogetherValidatorValidateAction(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + validator configvalidator.RequiredTogetherValidator + req action.ValidateConfigRequest + expected *action.ValidateConfigResponse + }{ + "no-diagnostics": { + validator: configvalidator.RequiredTogetherValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + }, + req: action.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ + Optional: true, + }, + "other": schema.StringAttribute{ + Optional: true, + }, + }, + }, + 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: &action.ValidateConfigResponse{}, + }, + "diagnostics": { + validator: configvalidator.RequiredTogetherValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + }, + req: action.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ + Optional: true, + }, + "test2": schema.StringAttribute{ + Optional: true, + }, + "other": schema.StringAttribute{ + Optional: true, + }, + }, + }, + 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: &action.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 { + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &action.ValidateConfigResponse{} + + testCase.validator.ValidateAction(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/internal/testvalidator/warning.go b/internal/testvalidator/warning.go index e2eb3578..86b1ed59 100644 --- a/internal/testvalidator/warning.go +++ b/internal/testvalidator/warning.go @@ -6,6 +6,7 @@ package testvalidator import ( "context" + "github.com/hashicorp/terraform-plugin-framework/action" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/ephemeral" "github.com/hashicorp/terraform-plugin-framework/list" @@ -14,6 +15,14 @@ import ( "github.com/hashicorp/terraform-plugin-framework/schema/validator" ) +// WarningAction returns a validator which returns a warning diagnostic. +func WarningAction(summary string, detail string) action.ConfigValidator { + return WarningValidator{ + Summary: summary, + Detail: detail, + } +} + // WarningBool returns a validator which returns a warning diagnostic. func WarningBool(summary string, detail string) validator.Bool { return WarningValidator{ @@ -172,6 +181,10 @@ func (v WarningValidator) MarkdownDescription(ctx context.Context) string { return v.Description(ctx) } +func (v WarningValidator) ValidateAction(ctx context.Context, request action.ValidateConfigRequest, response *action.ValidateConfigResponse) { + response.Diagnostics.AddWarning(v.Summary, v.Detail) +} + func (v WarningValidator) ValidateBool(ctx context.Context, request validator.BoolRequest, response *validator.BoolResponse) { response.Diagnostics.AddWarning(v.Summary, v.Detail) }