From 2166560cdcf48b6dec1a6cd714d135c558f13fce Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Tue, 13 Aug 2024 16:16:45 -0400 Subject: [PATCH 1/5] use WIP framework branch --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index ca22a56f..1b56e439 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ toolchain go1.21.6 require ( github.com/google/go-cmp v0.6.0 - github.com/hashicorp/terraform-plugin-framework v1.11.0 + github.com/hashicorp/terraform-plugin-framework v1.11.1-0.20240813194539-99fb820e8d0e github.com/hashicorp/terraform-plugin-go v0.23.0 ) diff --git a/go.sum b/go.sum index bc50ac2c..64dac8a1 100644 --- a/go.sum +++ b/go.sum @@ -7,8 +7,8 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/hashicorp/go-hclog v1.5.0 h1:bI2ocEMgcVlz55Oj1xZNBsVi900c7II+fWDyV9o+13c= github.com/hashicorp/go-hclog v1.5.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= -github.com/hashicorp/terraform-plugin-framework v1.11.0 h1:M7+9zBArexHFXDx/pKTxjE6n/2UCXY6b8FIq9ZYhwfE= -github.com/hashicorp/terraform-plugin-framework v1.11.0/go.mod h1:qBXLDn69kM97NNVi/MQ9qgd1uWWsVftGSnygYG1tImM= +github.com/hashicorp/terraform-plugin-framework v1.11.1-0.20240813194539-99fb820e8d0e h1:nbnGZ4idzvTPmkIUt1bpQnpRrV51AZmDuvDMnCoqEx4= +github.com/hashicorp/terraform-plugin-framework v1.11.1-0.20240813194539-99fb820e8d0e/go.mod h1:qBXLDn69kM97NNVi/MQ9qgd1uWWsVftGSnygYG1tImM= github.com/hashicorp/terraform-plugin-go v0.23.0 h1:AALVuU1gD1kPb48aPQUjug9Ir/125t+AAurhqphJ2Co= github.com/hashicorp/terraform-plugin-go v0.23.0/go.mod h1:1E3Cr9h2vMlahWMbsSEcNrOCxovCZhOOIXjFHbjc/lQ= github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= From ad532d94f40f0140fb88e728225c98b3b3278b69 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Tue, 13 Aug 2024 16:50:56 -0400 Subject: [PATCH 2/5] implementation of the shared config validators for ephemeral resources --- ephemeralvalidator/all.go | 57 +++++ ephemeralvalidator/all_example_test.go | 21 ++ ephemeralvalidator/all_test.go | 178 +++++++++++++++ ephemeralvalidator/any.go | 65 ++++++ ephemeralvalidator/any_example_test.go | 17 ++ ephemeralvalidator/any_test.go | 155 +++++++++++++ ephemeralvalidator/any_with_all_warnings.go | 67 ++++++ .../any_with_all_warnings_example_test.go | 17 ++ .../any_with_all_warnings_test.go | 216 ++++++++++++++++++ ephemeralvalidator/at_least_one_of.go | 18 ++ .../at_least_one_of_example_test.go | 22 ++ ephemeralvalidator/at_least_one_of_test.go | 123 ++++++++++ ephemeralvalidator/conflicting.go | 18 ++ .../conflicting_example_test.go | 22 ++ ephemeralvalidator/conflicting_test.go | 124 ++++++++++ ephemeralvalidator/doc.go | 13 ++ ephemeralvalidator/exactly_one_of.go | 18 ++ .../exactly_one_of_example_test.go | 22 ++ ephemeralvalidator/exactly_one_of_test.go | 124 ++++++++++ ephemeralvalidator/required_together.go | 18 ++ .../required_together_example_test.go | 22 ++ ephemeralvalidator/required_together_test.go | 124 ++++++++++ internal/configvalidator/at_least_one_of.go | 5 + .../configvalidator/at_least_one_of_test.go | 109 +++++++++ internal/configvalidator/conflicting.go | 5 + internal/configvalidator/conflicting_test.go | 110 +++++++++ internal/configvalidator/doc.go | 4 +- internal/configvalidator/exactly_one_of.go | 5 + .../configvalidator/exactly_one_of_test.go | 110 +++++++++ internal/configvalidator/required_together.go | 5 + .../configvalidator/required_together_test.go | 110 +++++++++ internal/testvalidator/warning.go | 13 ++ 32 files changed, 1935 insertions(+), 2 deletions(-) create mode 100644 ephemeralvalidator/all.go create mode 100644 ephemeralvalidator/all_example_test.go create mode 100644 ephemeralvalidator/all_test.go create mode 100644 ephemeralvalidator/any.go create mode 100644 ephemeralvalidator/any_example_test.go create mode 100644 ephemeralvalidator/any_test.go create mode 100644 ephemeralvalidator/any_with_all_warnings.go create mode 100644 ephemeralvalidator/any_with_all_warnings_example_test.go create mode 100644 ephemeralvalidator/any_with_all_warnings_test.go create mode 100644 ephemeralvalidator/at_least_one_of.go create mode 100644 ephemeralvalidator/at_least_one_of_example_test.go create mode 100644 ephemeralvalidator/at_least_one_of_test.go create mode 100644 ephemeralvalidator/conflicting.go create mode 100644 ephemeralvalidator/conflicting_example_test.go create mode 100644 ephemeralvalidator/conflicting_test.go create mode 100644 ephemeralvalidator/doc.go create mode 100644 ephemeralvalidator/exactly_one_of.go create mode 100644 ephemeralvalidator/exactly_one_of_example_test.go create mode 100644 ephemeralvalidator/exactly_one_of_test.go create mode 100644 ephemeralvalidator/required_together.go create mode 100644 ephemeralvalidator/required_together_example_test.go create mode 100644 ephemeralvalidator/required_together_test.go diff --git a/ephemeralvalidator/all.go b/ephemeralvalidator/all.go new file mode 100644 index 00000000..7ee46787 --- /dev/null +++ b/ephemeralvalidator/all.go @@ -0,0 +1,57 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ephemeralvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/ephemeral" +) + +// 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 ...ephemeral.ConfigValidator) ephemeral.ConfigValidator { + return allValidator{ + validators: validators, + } +} + +var _ ephemeral.ConfigValidator = allValidator{} + +// allValidator implements the validator. +type allValidator struct { + validators []ephemeral.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) +} + +// ValidateEphemeralResource performs the validation. +func (v allValidator) ValidateEphemeralResource(ctx context.Context, req ephemeral.ValidateConfigRequest, resp *ephemeral.ValidateConfigResponse) { + for _, subValidator := range v.validators { + validateResp := &ephemeral.ValidateConfigResponse{} + + subValidator.ValidateEphemeralResource(ctx, req, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) + } +} diff --git a/ephemeralvalidator/all_example_test.go b/ephemeralvalidator/all_example_test.go new file mode 100644 index 00000000..72f8c648 --- /dev/null +++ b/ephemeralvalidator/all_example_test.go @@ -0,0 +1,21 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ephemeralvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + + "github.com/hashicorp/terraform-plugin-framework-validators/ephemeralvalidator" +) + +func ExampleAll() { + // Used inside a ephemeral.EphemeralResource type ConfigValidators method + _ = []ephemeral.ConfigValidator{ + // The configuration must satisfy either All validator. + ephemeralvalidator.Any( + ephemeralvalidator.All( /* ... */ ), + ephemeralvalidator.All( /* ... */ ), + ), + } +} diff --git a/ephemeralvalidator/all_test.go b/ephemeralvalidator/all_test.go new file mode 100644 index 00000000..c8b158bf --- /dev/null +++ b/ephemeralvalidator/all_test.go @@ -0,0 +1,178 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ephemeralvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/ephemeral/schema" + "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/ephemeralvalidator" +) + +func TestAllValidatorValidateEphemeralResource(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + validators []ephemeral.ConfigValidator + req ephemeral.ValidateConfigRequest + expected *ephemeral.ValidateConfigResponse + }{ + "no-diagnostics": { + validators: []ephemeral.ConfigValidator{ + ephemeralvalidator.ExactlyOneOf( + path.MatchRoot("test1"), + path.MatchRoot("test2"), + ), + ephemeralvalidator.All( + ephemeralvalidator.AtLeastOneOf( + path.MatchRoot("test3"), + path.MatchRoot("test4"), + ), + ephemeralvalidator.Conflicting( + path.MatchRoot("test3"), + path.MatchRoot("test5"), + ), + ), + }, + req: ephemeral.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.Schema{ + 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: &ephemeral.ValidateConfigResponse{}, + }, + "diagnostics": { + validators: []ephemeral.ConfigValidator{ + ephemeralvalidator.ExactlyOneOf( + path.MatchRoot("test1"), + path.MatchRoot("test2"), + ), + ephemeralvalidator.All( + ephemeralvalidator.AtLeastOneOf( + path.MatchRoot("test3"), + path.MatchRoot("test4"), + ), + ephemeralvalidator.Conflicting( + path.MatchRoot("test3"), + path.MatchRoot("test5"), + ), + ), + }, + req: ephemeral.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.Schema{ + 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: &ephemeral.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 { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &ephemeral.ValidateConfigResponse{} + + ephemeralvalidator.Any(testCase.validators...).ValidateEphemeralResource(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/ephemeralvalidator/any.go b/ephemeralvalidator/any.go new file mode 100644 index 00000000..4c3ea6e8 --- /dev/null +++ b/ephemeralvalidator/any.go @@ -0,0 +1,65 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ephemeralvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/ephemeral" +) + +// 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 ...ephemeral.ConfigValidator) ephemeral.ConfigValidator { + return anyValidator{ + validators: validators, + } +} + +var _ ephemeral.ConfigValidator = anyValidator{} + +// anyValidator implements the validator. +type anyValidator struct { + validators []ephemeral.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) +} + +// ValidateEphemeralResource performs the validation. +func (v anyValidator) ValidateEphemeralResource(ctx context.Context, req ephemeral.ValidateConfigRequest, resp *ephemeral.ValidateConfigResponse) { + for _, subValidator := range v.validators { + validateResp := &ephemeral.ValidateConfigResponse{} + + subValidator.ValidateEphemeralResource(ctx, req, validateResp) + + if !validateResp.Diagnostics.HasError() { + resp.Diagnostics = validateResp.Diagnostics + + return + } + + resp.Diagnostics.Append(validateResp.Diagnostics...) + } +} diff --git a/ephemeralvalidator/any_example_test.go b/ephemeralvalidator/any_example_test.go new file mode 100644 index 00000000..30e98f1d --- /dev/null +++ b/ephemeralvalidator/any_example_test.go @@ -0,0 +1,17 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ephemeralvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + + "github.com/hashicorp/terraform-plugin-framework-validators/ephemeralvalidator" +) + +func ExampleAny() { + // Used inside a ephemeral.EphemeralResource type ConfigValidators method + _ = []ephemeral.ConfigValidator{ + ephemeralvalidator.Any( /* ... */ ), + } +} diff --git a/ephemeralvalidator/any_test.go b/ephemeralvalidator/any_test.go new file mode 100644 index 00000000..aa990286 --- /dev/null +++ b/ephemeralvalidator/any_test.go @@ -0,0 +1,155 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ephemeralvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/ephemeral/schema" + "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/ephemeralvalidator" +) + +func TestAnyValidatorValidateEphemeralResource(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + validators []ephemeral.ConfigValidator + req ephemeral.ValidateConfigRequest + expected *ephemeral.ValidateConfigResponse + }{ + "no-diagnostics": { + validators: []ephemeral.ConfigValidator{ + ephemeralvalidator.ExactlyOneOf( + path.MatchRoot("test1"), + path.MatchRoot("test2"), + ), + ephemeralvalidator.ExactlyOneOf( + path.MatchRoot("test3"), + path.MatchRoot("test4"), + ), + }, + req: ephemeral.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.Schema{ + 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: &ephemeral.ValidateConfigResponse{}, + }, + "diagnostics": { + validators: []ephemeral.ConfigValidator{ + ephemeralvalidator.ExactlyOneOf( + path.MatchRoot("test1"), + path.MatchRoot("test2"), + ), + ephemeralvalidator.ExactlyOneOf( + path.MatchRoot("test3"), + path.MatchRoot("test4"), + ), + }, + req: ephemeral.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.Schema{ + 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: &ephemeral.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 { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &ephemeral.ValidateConfigResponse{} + + ephemeralvalidator.Any(testCase.validators...).ValidateEphemeralResource(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/ephemeralvalidator/any_with_all_warnings.go b/ephemeralvalidator/any_with_all_warnings.go new file mode 100644 index 00000000..f840d115 --- /dev/null +++ b/ephemeralvalidator/any_with_all_warnings.go @@ -0,0 +1,67 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ephemeralvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/ephemeral" +) + +// 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 ...ephemeral.ConfigValidator) ephemeral.ConfigValidator { + return anyWithAllWarningsValidator{ + validators: validators, + } +} + +var _ ephemeral.ConfigValidator = anyWithAllWarningsValidator{} + +// anyWithAllWarningsValidator implements the validator. +type anyWithAllWarningsValidator struct { + validators []ephemeral.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) +} + +// ValidateEphemeralResource performs the validation. +func (v anyWithAllWarningsValidator) ValidateEphemeralResource(ctx context.Context, req ephemeral.ValidateConfigRequest, resp *ephemeral.ValidateConfigResponse) { + anyValid := false + + for _, subValidator := range v.validators { + validateResp := &ephemeral.ValidateConfigResponse{} + + subValidator.ValidateEphemeralResource(ctx, req, validateResp) + + if !validateResp.Diagnostics.HasError() { + anyValid = true + } + + resp.Diagnostics.Append(validateResp.Diagnostics...) + } + + if anyValid { + resp.Diagnostics = resp.Diagnostics.Warnings() + } +} diff --git a/ephemeralvalidator/any_with_all_warnings_example_test.go b/ephemeralvalidator/any_with_all_warnings_example_test.go new file mode 100644 index 00000000..83c05933 --- /dev/null +++ b/ephemeralvalidator/any_with_all_warnings_example_test.go @@ -0,0 +1,17 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ephemeralvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + + "github.com/hashicorp/terraform-plugin-framework-validators/ephemeralvalidator" +) + +func ExampleAnyWithAllWarnings() { + // Used inside a ephemeral.EphemeralResource type ConfigValidators method + _ = []ephemeral.ConfigValidator{ + ephemeralvalidator.AnyWithAllWarnings( /* ... */ ), + } +} diff --git a/ephemeralvalidator/any_with_all_warnings_test.go b/ephemeralvalidator/any_with_all_warnings_test.go new file mode 100644 index 00000000..c57c7547 --- /dev/null +++ b/ephemeralvalidator/any_with_all_warnings_test.go @@ -0,0 +1,216 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ephemeralvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/ephemeral/schema" + "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/ephemeralvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/internal/testvalidator" +) + +func TestAnyWithAllWarningsValidatorValidateEphemeralResource(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + validators []ephemeral.ConfigValidator + req ephemeral.ValidateConfigRequest + expected *ephemeral.ValidateConfigResponse + }{ + "valid": { + validators: []ephemeral.ConfigValidator{ + ephemeralvalidator.ExactlyOneOf( + path.MatchRoot("test1"), + path.MatchRoot("test2"), + ), + ephemeralvalidator.ExactlyOneOf( + path.MatchRoot("test3"), + path.MatchRoot("test4"), + ), + }, + req: ephemeral.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.Schema{ + 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: &ephemeral.ValidateConfigResponse{}, + }, + "valid with warning": { + validators: []ephemeral.ConfigValidator{ + ephemeralvalidator.All( + ephemeralvalidator.ExactlyOneOf( + path.MatchRoot("test1"), + path.MatchRoot("test2"), + ), + testvalidator.WarningEphemeralResource("failing warning summary", "failing warning details"), + ), + ephemeralvalidator.All( + ephemeralvalidator.ExactlyOneOf( + path.MatchRoot("test3"), + path.MatchRoot("test4"), + ), + testvalidator.WarningEphemeralResource("passing warning summary", "passing warning details"), + ), + }, + req: ephemeral.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.Schema{ + 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: &ephemeral.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewWarningDiagnostic("failing warning summary", "failing warning details"), + diag.NewWarningDiagnostic("passing warning summary", "passing warning details"), + }, + }, + }, + "invalid": { + validators: []ephemeral.ConfigValidator{ + ephemeralvalidator.ExactlyOneOf( + path.MatchRoot("test1"), + path.MatchRoot("test2"), + ), + ephemeralvalidator.ExactlyOneOf( + path.MatchRoot("test3"), + path.MatchRoot("test4"), + ), + }, + req: ephemeral.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.Schema{ + 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: &ephemeral.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 { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &ephemeral.ValidateConfigResponse{} + + ephemeralvalidator.AnyWithAllWarnings(testCase.validators...).ValidateEphemeralResource(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/ephemeralvalidator/at_least_one_of.go b/ephemeralvalidator/at_least_one_of.go new file mode 100644 index 00000000..8be3bc9a --- /dev/null +++ b/ephemeralvalidator/at_least_one_of.go @@ -0,0 +1,18 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ephemeralvalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/configvalidator" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "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) ephemeral.ConfigValidator { + return &configvalidator.AtLeastOneOfValidator{ + PathExpressions: expressions, + } +} diff --git a/ephemeralvalidator/at_least_one_of_example_test.go b/ephemeralvalidator/at_least_one_of_example_test.go new file mode 100644 index 00000000..d3eeb4f4 --- /dev/null +++ b/ephemeralvalidator/at_least_one_of_example_test.go @@ -0,0 +1,22 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ephemeralvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/ephemeralvalidator" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/path" +) + +func ExampleAtLeastOneOf() { + // Used inside a ephemeral.EphemeralResource type ConfigValidators method + _ = []ephemeral.ConfigValidator{ + // Validate at least one of the schema defined attributes named attr1 + // and attr2 has a known, non-null value. + ephemeralvalidator.AtLeastOneOf( + path.MatchRoot("attr1"), + path.MatchRoot("attr2"), + ), + } +} diff --git a/ephemeralvalidator/at_least_one_of_test.go b/ephemeralvalidator/at_least_one_of_test.go new file mode 100644 index 00000000..1c40aa7c --- /dev/null +++ b/ephemeralvalidator/at_least_one_of_test.go @@ -0,0 +1,123 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ephemeralvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework-validators/ephemeralvalidator" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/ephemeral/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestAtLeastOneOf(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + pathExpressions path.Expressions + req ephemeral.ValidateConfigRequest + expected *ephemeral.ValidateConfigResponse + }{ + "no-diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + req: ephemeral.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: &ephemeral.ValidateConfigResponse{}, + }, + "diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + req: ephemeral.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: &ephemeral.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 := ephemeralvalidator.AtLeastOneOf(testCase.pathExpressions...) + got := &ephemeral.ValidateConfigResponse{} + + validator.ValidateEphemeralResource(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/ephemeralvalidator/conflicting.go b/ephemeralvalidator/conflicting.go new file mode 100644 index 00000000..8ac8eac9 --- /dev/null +++ b/ephemeralvalidator/conflicting.go @@ -0,0 +1,18 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ephemeralvalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/configvalidator" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/path" +) + +// Conflicting checks that a set of path.Expression, are not configured +// simultaneously. +func Conflicting(expressions ...path.Expression) ephemeral.ConfigValidator { + return &configvalidator.ConflictingValidator{ + PathExpressions: expressions, + } +} diff --git a/ephemeralvalidator/conflicting_example_test.go b/ephemeralvalidator/conflicting_example_test.go new file mode 100644 index 00000000..bb1c2ca8 --- /dev/null +++ b/ephemeralvalidator/conflicting_example_test.go @@ -0,0 +1,22 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ephemeralvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/ephemeralvalidator" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/path" +) + +func ExampleConflicting() { + // Used inside a ephemeral.EphemeralResource type ConfigValidators method + _ = []ephemeral.ConfigValidator{ + // Validate that schema defined attributes named attr1 and attr2 are not + // both configured with known, non-null values. + ephemeralvalidator.Conflicting( + path.MatchRoot("attr1"), + path.MatchRoot("attr2"), + ), + } +} diff --git a/ephemeralvalidator/conflicting_test.go b/ephemeralvalidator/conflicting_test.go new file mode 100644 index 00000000..b8044b18 --- /dev/null +++ b/ephemeralvalidator/conflicting_test.go @@ -0,0 +1,124 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ephemeralvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework-validators/ephemeralvalidator" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/ephemeral/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestConflicting(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + pathExpressions path.Expressions + req ephemeral.ValidateConfigRequest + expected *ephemeral.ValidateConfigResponse + }{ + "no-diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + req: ephemeral.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: &ephemeral.ValidateConfigResponse{}, + }, + "diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + req: ephemeral.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: &ephemeral.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 := ephemeralvalidator.Conflicting(testCase.pathExpressions...) + got := &ephemeral.ValidateConfigResponse{} + + validator.ValidateEphemeralResource(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/ephemeralvalidator/doc.go b/ephemeralvalidator/doc.go new file mode 100644 index 00000000..52fd596b --- /dev/null +++ b/ephemeralvalidator/doc.go @@ -0,0 +1,13 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +// Package ephemeralvalidator provides validators to express relationships +// between multiple attributes of an ephemeral 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 ephemeralvalidator diff --git a/ephemeralvalidator/exactly_one_of.go b/ephemeralvalidator/exactly_one_of.go new file mode 100644 index 00000000..dfe2a8f8 --- /dev/null +++ b/ephemeralvalidator/exactly_one_of.go @@ -0,0 +1,18 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ephemeralvalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/configvalidator" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "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) ephemeral.ConfigValidator { + return &configvalidator.ExactlyOneOfValidator{ + PathExpressions: expressions, + } +} diff --git a/ephemeralvalidator/exactly_one_of_example_test.go b/ephemeralvalidator/exactly_one_of_example_test.go new file mode 100644 index 00000000..7581f183 --- /dev/null +++ b/ephemeralvalidator/exactly_one_of_example_test.go @@ -0,0 +1,22 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ephemeralvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/ephemeralvalidator" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/path" +) + +func ExampleExactlyOneOf() { + // Used inside a ephemeral.EphemeralResource type ConfigValidators method + _ = []ephemeral.ConfigValidator{ + // Validate only one of the schema defined attributes named attr1 + // and attr2 has a known, non-null value. + ephemeralvalidator.ExactlyOneOf( + path.MatchRoot("attr1"), + path.MatchRoot("attr2"), + ), + } +} diff --git a/ephemeralvalidator/exactly_one_of_test.go b/ephemeralvalidator/exactly_one_of_test.go new file mode 100644 index 00000000..a205ec1f --- /dev/null +++ b/ephemeralvalidator/exactly_one_of_test.go @@ -0,0 +1,124 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ephemeralvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework-validators/ephemeralvalidator" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/ephemeral/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestExactlyOneOf(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + pathExpressions path.Expressions + req ephemeral.ValidateConfigRequest + expected *ephemeral.ValidateConfigResponse + }{ + "no-diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + req: ephemeral.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: &ephemeral.ValidateConfigResponse{}, + }, + "diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + req: ephemeral.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: &ephemeral.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 := ephemeralvalidator.ExactlyOneOf(testCase.pathExpressions...) + got := &ephemeral.ValidateConfigResponse{} + + validator.ValidateEphemeralResource(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/ephemeralvalidator/required_together.go b/ephemeralvalidator/required_together.go new file mode 100644 index 00000000..565f4c0c --- /dev/null +++ b/ephemeralvalidator/required_together.go @@ -0,0 +1,18 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ephemeralvalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/configvalidator" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "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) ephemeral.ConfigValidator { + return &configvalidator.RequiredTogetherValidator{ + PathExpressions: expressions, + } +} diff --git a/ephemeralvalidator/required_together_example_test.go b/ephemeralvalidator/required_together_example_test.go new file mode 100644 index 00000000..906ec1e4 --- /dev/null +++ b/ephemeralvalidator/required_together_example_test.go @@ -0,0 +1,22 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ephemeralvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/ephemeralvalidator" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/path" +) + +func ExampleRequiredTogether() { + // Used inside a ephemeral.EphemeralResource type ConfigValidators method + _ = []ephemeral.ConfigValidator{ + // Validate the schema defined attributes named attr1 and attr2 are either + // both null or both known values. + ephemeralvalidator.RequiredTogether( + path.MatchRoot("attr1"), + path.MatchRoot("attr2"), + ), + } +} diff --git a/ephemeralvalidator/required_together_test.go b/ephemeralvalidator/required_together_test.go new file mode 100644 index 00000000..9dd514d2 --- /dev/null +++ b/ephemeralvalidator/required_together_test.go @@ -0,0 +1,124 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ephemeralvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework-validators/ephemeralvalidator" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/ephemeral/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestRequiredTogether(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + pathExpressions path.Expressions + req ephemeral.ValidateConfigRequest + expected *ephemeral.ValidateConfigResponse + }{ + "no-diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + req: ephemeral.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: &ephemeral.ValidateConfigResponse{}, + }, + "diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + req: ephemeral.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: &ephemeral.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 := ephemeralvalidator.RequiredTogether(testCase.pathExpressions...) + got := &ephemeral.ValidateConfigResponse{} + + validator.ValidateEphemeralResource(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 9d67ef4a..03a32a35 100644 --- a/internal/configvalidator/at_least_one_of.go +++ b/internal/configvalidator/at_least_one_of.go @@ -10,6 +10,7 @@ import ( "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/ephemeral" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/resource" @@ -45,6 +46,10 @@ func (v AtLeastOneOfValidator) ValidateResource(ctx context.Context, req resourc resp.Diagnostics = v.Validate(ctx, req.Config) } +func (v AtLeastOneOfValidator) ValidateEphemeralResource(ctx context.Context, req ephemeral.ValidateConfigRequest, resp *ephemeral.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 diff --git a/internal/configvalidator/at_least_one_of_test.go b/internal/configvalidator/at_least_one_of_test.go index 9b583649..94eeb86f 100644 --- a/internal/configvalidator/at_least_one_of_test.go +++ b/internal/configvalidator/at_least_one_of_test.go @@ -10,6 +10,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/resource" @@ -934,3 +935,111 @@ func TestAtLeastOneOfValidatorValidateResource(t *testing.T) { }) } } + +func TestAtLeastOneOfValidatorValidateEphemeralResource(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + validator configvalidator.AtLeastOneOfValidator + req ephemeral.ValidateConfigRequest + expected *ephemeral.ValidateConfigResponse + }{ + "no-diagnostics": { + validator: configvalidator.AtLeastOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + }, + req: ephemeral.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: &ephemeral.ValidateConfigResponse{}, + }, + "diagnostics": { + validator: configvalidator.AtLeastOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + }, + req: ephemeral.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: &ephemeral.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 := &ephemeral.ValidateConfigResponse{} + + testCase.validator.ValidateEphemeralResource(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 edd2abd8..38dfd5c4 100644 --- a/internal/configvalidator/conflicting.go +++ b/internal/configvalidator/conflicting.go @@ -11,6 +11,7 @@ import ( "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/ephemeral" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/resource" @@ -46,6 +47,10 @@ func (v ConflictingValidator) ValidateResource(ctx context.Context, req resource resp.Diagnostics = v.Validate(ctx, req.Config) } +func (v ConflictingValidator) ValidateEphemeralResource(ctx context.Context, req ephemeral.ValidateConfigRequest, resp *ephemeral.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 diff --git a/internal/configvalidator/conflicting_test.go b/internal/configvalidator/conflicting_test.go index d9b63495..1f513f27 100644 --- a/internal/configvalidator/conflicting_test.go +++ b/internal/configvalidator/conflicting_test.go @@ -10,6 +10,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/resource" @@ -941,3 +942,112 @@ func TestConflictingValidatorValidateResource(t *testing.T) { }) } } + +func TestConflictingValidatorValidateEphemeralResource(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + validator configvalidator.ConflictingValidator + req ephemeral.ValidateConfigRequest + expected *ephemeral.ValidateConfigResponse + }{ + "no-diagnostics": { + validator: configvalidator.ConflictingValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + }, + req: ephemeral.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: &ephemeral.ValidateConfigResponse{}, + }, + "diagnostics": { + validator: configvalidator.ConflictingValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + }, + req: ephemeral.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: &ephemeral.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 := &ephemeral.ValidateConfigResponse{} + + testCase.validator.ValidateEphemeralResource(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 789b4159..b3533ae2 100644 --- a/internal/configvalidator/doc.go +++ b/internal/configvalidator/doc.go @@ -2,6 +2,6 @@ // SPDX-License-Identifier: MPL-2.0 // Package configvalidator provides the generic configuration validator -// implementations for the exported datasourcevalidator, providervalidator, and -// resourcevalidator packages. +// implementations for the exported datasourcevalidator, providervalidator, +// resourcevalidator, and ephemeralvalidator packages. package configvalidator diff --git a/internal/configvalidator/exactly_one_of.go b/internal/configvalidator/exactly_one_of.go index b76786f8..14904d5a 100644 --- a/internal/configvalidator/exactly_one_of.go +++ b/internal/configvalidator/exactly_one_of.go @@ -11,6 +11,7 @@ import ( "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/ephemeral" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/resource" @@ -46,6 +47,10 @@ func (v ExactlyOneOfValidator) ValidateResource(ctx context.Context, req resourc resp.Diagnostics = v.Validate(ctx, req.Config) } +func (v ExactlyOneOfValidator) ValidateEphemeralResource(ctx context.Context, req ephemeral.ValidateConfigRequest, resp *ephemeral.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 diff --git a/internal/configvalidator/exactly_one_of_test.go b/internal/configvalidator/exactly_one_of_test.go index fd85bf9b..805c2ed8 100644 --- a/internal/configvalidator/exactly_one_of_test.go +++ b/internal/configvalidator/exactly_one_of_test.go @@ -10,6 +10,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/resource" @@ -961,3 +962,112 @@ func TestExactlyOneOfValidatorValidateResource(t *testing.T) { }) } } + +func TestExactlyOneOfValidatorValidateEphemeralResource(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + validator configvalidator.ExactlyOneOfValidator + req ephemeral.ValidateConfigRequest + expected *ephemeral.ValidateConfigResponse + }{ + "no-diagnostics": { + validator: configvalidator.ExactlyOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + }, + req: ephemeral.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: &ephemeral.ValidateConfigResponse{}, + }, + "diagnostics": { + validator: configvalidator.ExactlyOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + }, + req: ephemeral.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: &ephemeral.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 := &ephemeral.ValidateConfigResponse{} + + testCase.validator.ValidateEphemeralResource(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 69c46671..e694e91d 100644 --- a/internal/configvalidator/required_together.go +++ b/internal/configvalidator/required_together.go @@ -11,6 +11,7 @@ import ( "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/ephemeral" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/resource" @@ -46,6 +47,10 @@ func (v RequiredTogetherValidator) ValidateResource(ctx context.Context, req res resp.Diagnostics = v.Validate(ctx, req.Config) } +func (v RequiredTogetherValidator) ValidateEphemeralResource(ctx context.Context, req ephemeral.ValidateConfigRequest, resp *ephemeral.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 diff --git a/internal/configvalidator/required_together_test.go b/internal/configvalidator/required_together_test.go index 7f7a1c9f..fd7f04f7 100644 --- a/internal/configvalidator/required_together_test.go +++ b/internal/configvalidator/required_together_test.go @@ -10,6 +10,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/resource" @@ -929,3 +930,112 @@ func TestRequiredTogetherValidatorValidateResource(t *testing.T) { }) } } + +func TestRequiredTogetherValidatorValidateEphemeralResource(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + validator configvalidator.RequiredTogetherValidator + req ephemeral.ValidateConfigRequest + expected *ephemeral.ValidateConfigResponse + }{ + "no-diagnostics": { + validator: configvalidator.RequiredTogetherValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + }, + req: ephemeral.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: &ephemeral.ValidateConfigResponse{}, + }, + "diagnostics": { + validator: configvalidator.RequiredTogetherValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + }, + req: ephemeral.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: &ephemeral.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 := &ephemeral.ValidateConfigResponse{} + + testCase.validator.ValidateEphemeralResource(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 f92a14ac..f988ed99 100644 --- a/internal/testvalidator/warning.go +++ b/internal/testvalidator/warning.go @@ -7,6 +7,7 @@ import ( "context" "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/schema/validator" @@ -108,6 +109,14 @@ func WarningResource(summary string, detail string) resource.ConfigValidator { } } +// WarningEphemeralResource returns a validator which returns a warning diagnostic. +func WarningEphemeralResource(summary string, detail string) ephemeral.ConfigValidator { + return WarningValidator{ + Summary: summary, + Detail: detail, + } +} + // WarningSet returns a validator which returns a warning diagnostic. func WarningSet(summary string, detail string) validator.Set { return WarningValidator{ @@ -202,6 +211,10 @@ func (v WarningValidator) ValidateResource(ctx context.Context, request resource response.Diagnostics.AddWarning(v.Summary, v.Detail) } +func (v WarningValidator) ValidateEphemeralResource(ctx context.Context, request ephemeral.ValidateConfigRequest, response *ephemeral.ValidateConfigResponse) { + response.Diagnostics.AddWarning(v.Summary, v.Detail) +} + func (v WarningValidator) ValidateSet(ctx context.Context, request validator.SetRequest, response *validator.SetResponse) { response.Diagnostics.AddWarning(v.Summary, v.Detail) } From 7e8bcb4541b7d41c4a1839484272dc4cf2363496 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Wed, 30 Oct 2024 16:36:45 -0400 Subject: [PATCH 3/5] update go mod --- go.mod | 8 ++++---- go.sum | 16 +++++++++------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/go.mod b/go.mod index 1ebd9191..124c6225 100644 --- a/go.mod +++ b/go.mod @@ -6,8 +6,8 @@ toolchain go1.22.7 require ( github.com/google/go-cmp v0.6.0 - github.com/hashicorp/terraform-plugin-framework v1.12.0 - github.com/hashicorp/terraform-plugin-go v0.24.0 + github.com/hashicorp/terraform-plugin-framework v1.12.1-0.20241030202920-370da65c406b + github.com/hashicorp/terraform-plugin-go v0.25.0 ) require ( @@ -15,9 +15,9 @@ require ( github.com/hashicorp/go-hclog v1.5.0 // indirect github.com/hashicorp/terraform-plugin-log v0.9.0 // indirect github.com/mattn/go-colorable v0.1.12 // indirect - github.com/mattn/go-isatty v0.0.14 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect github.com/mitchellh/go-testing-interface v1.14.1 // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect - golang.org/x/sys v0.21.0 // indirect + golang.org/x/sys v0.24.0 // indirect ) diff --git a/go.sum b/go.sum index 3521a4c7..89509d4c 100644 --- a/go.sum +++ b/go.sum @@ -7,18 +7,19 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/hashicorp/go-hclog v1.5.0 h1:bI2ocEMgcVlz55Oj1xZNBsVi900c7II+fWDyV9o+13c= github.com/hashicorp/go-hclog v1.5.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= -github.com/hashicorp/terraform-plugin-framework v1.12.0 h1:7HKaueHPaikX5/7cbC1r9d1m12iYHY+FlNZEGxQ42CQ= -github.com/hashicorp/terraform-plugin-framework v1.12.0/go.mod h1:N/IOQ2uYjW60Jp39Cp3mw7I/OpC/GfZ0385R0YibmkE= -github.com/hashicorp/terraform-plugin-go v0.24.0 h1:2WpHhginCdVhFIrWHxDEg6RBn3YaWzR2o6qUeIEat2U= -github.com/hashicorp/terraform-plugin-go v0.24.0/go.mod h1:tUQ53lAsOyYSckFGEefGC5C8BAaO0ENqzFd3bQeuYQg= +github.com/hashicorp/terraform-plugin-framework v1.12.1-0.20241030202920-370da65c406b h1:UVByWUWjR/vdMPX/Bc22sZwW/6ym/NYKSmJy44NLxyU= +github.com/hashicorp/terraform-plugin-framework v1.12.1-0.20241030202920-370da65c406b/go.mod h1:j64rwMGpgM3NYXTKuxrCnyubQb/4VKldEKlcG8cvmjU= +github.com/hashicorp/terraform-plugin-go v0.25.0 h1:oi13cx7xXA6QciMcpcFi/rwA974rdTxjqEhXJjbAyks= +github.com/hashicorp/terraform-plugin-go v0.25.0/go.mod h1:+SYagMYadJP86Kvn+TGeV+ofr/R3g4/If0O5sO96MVw= github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= github.com/hashicorp/terraform-plugin-log v0.9.0/go.mod h1:rKL8egZQ/eXSyDqzLUuwUYLVdlYeamldAHSxjUFADow= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -35,8 +36,9 @@ golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= -golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= +golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From b944c878696bac37c75913d7c55529a1071cf122 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Wed, 30 Oct 2024 16:41:37 -0400 Subject: [PATCH 4/5] go mod tidy --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index d7cc71f3..124c6225 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ toolchain go1.22.7 require ( github.com/google/go-cmp v0.6.0 - github.com/hashicorp/terraform-plugin-framework v1.12.0 + github.com/hashicorp/terraform-plugin-framework v1.12.1-0.20241030202920-370da65c406b github.com/hashicorp/terraform-plugin-go v0.25.0 ) diff --git a/go.sum b/go.sum index c502c33e..89509d4c 100644 --- a/go.sum +++ b/go.sum @@ -7,8 +7,8 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/hashicorp/go-hclog v1.5.0 h1:bI2ocEMgcVlz55Oj1xZNBsVi900c7II+fWDyV9o+13c= github.com/hashicorp/go-hclog v1.5.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= -github.com/hashicorp/terraform-plugin-framework v1.12.0 h1:7HKaueHPaikX5/7cbC1r9d1m12iYHY+FlNZEGxQ42CQ= -github.com/hashicorp/terraform-plugin-framework v1.12.0/go.mod h1:N/IOQ2uYjW60Jp39Cp3mw7I/OpC/GfZ0385R0YibmkE= +github.com/hashicorp/terraform-plugin-framework v1.12.1-0.20241030202920-370da65c406b h1:UVByWUWjR/vdMPX/Bc22sZwW/6ym/NYKSmJy44NLxyU= +github.com/hashicorp/terraform-plugin-framework v1.12.1-0.20241030202920-370da65c406b/go.mod h1:j64rwMGpgM3NYXTKuxrCnyubQb/4VKldEKlcG8cvmjU= github.com/hashicorp/terraform-plugin-go v0.25.0 h1:oi13cx7xXA6QciMcpcFi/rwA974rdTxjqEhXJjbAyks= github.com/hashicorp/terraform-plugin-go v0.25.0/go.mod h1:+SYagMYadJP86Kvn+TGeV+ofr/R3g4/If0O5sO96MVw= github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= From 8b5d8ebc914ddba5d3184691ce67982c17ce90f3 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Wed, 30 Oct 2024 16:46:31 -0400 Subject: [PATCH 5/5] changelog --- .changes/unreleased/FEATURES-20241030-164618.yaml | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changes/unreleased/FEATURES-20241030-164618.yaml diff --git a/.changes/unreleased/FEATURES-20241030-164618.yaml b/.changes/unreleased/FEATURES-20241030-164618.yaml new file mode 100644 index 00000000..957c73cc --- /dev/null +++ b/.changes/unreleased/FEATURES-20241030-164618.yaml @@ -0,0 +1,6 @@ +kind: FEATURES +body: 'ephemeralvalidator: Introduce new package with declarative validators for ephemeral + resource configurations' +time: 2024-10-30T16:46:18.935223-04:00 +custom: + Issue: "242"