diff --git a/.changelog/80.txt b/.changelog/80.txt new file mode 100644 index 0000000..e024623 --- /dev/null +++ b/.changelog/80.txt @@ -0,0 +1,31 @@ +```release-note:breaking-change +all: Migrated implementations to support terraform-plugin-framework version 0.17.0 `datasource/schema`, `provider/schema`, and `resource/schema` packages with type-specific validation +``` + +```release-note:breaking-change +listvalidator: The `ValuesAre` validator has been removed and split into element type-specific validators in the same package, such as `StringValuesAre` +``` + +```release-note:breaking-change +mapvalidator: The `ValuesAre` validator has been removed and split into element type-specific validators in the same package, such as `StringValuesAre` +``` + +```release-note:breaking-change +metavalidator: The `All` and `Any` validators have been removed and split into type-specific packages, such as `stringvalidator.Any` +``` + +```release-note:breaking-change +schemavalidator: The `AlsoRequires`, `AtLeastOneOf`, `ConflictsWith`, and `ExactlyOneOf` validators have been removed and split into type-specific packages, such as `stringvalidator.ConflictsWith` +``` + +```release-note:breaking-change +setvalidator: The `ValuesAre` validator has been removed and split into element type-specific validators in the same package, such as `StringValuesAre` +``` + +```release-note:feature +boolvalidator: New package which contains boolean type specific validators +``` + +```release-note:feature +objectvalidator: New package which contains object type specific validators +``` diff --git a/.gitignore b/.gitignore index 6ba0a49..6f801e9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +go.work +go.work.sum # JetBrains IDEs project files .idea/ *.iws diff --git a/boolvalidator/also_requires.go b/boolvalidator/also_requires.go new file mode 100644 index 0000000..b2c7853 --- /dev/null +++ b/boolvalidator/also_requires.go @@ -0,0 +1,23 @@ +package boolvalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// AlsoRequires checks that a set of path.Expression has a non-null value, +// if the current attribute or block also has a non-null value. +// +// This implements the validation logic declaratively within the schema. +// Refer to [datasourcevalidator.RequiredTogether], +// [providervalidator.RequiredTogether], or [resourcevalidator.RequiredTogether] +// for declaring this type of validation outside the schema definition. +// +// Relative path.Expression will be resolved using the attribute or block +// being validated. +func AlsoRequires(expressions ...path.Expression) validator.Bool { + return schemavalidator.AlsoRequiresValidator{ + PathExpressions: expressions, + } +} diff --git a/boolvalidator/also_requires_example_test.go b/boolvalidator/also_requires_example_test.go new file mode 100644 index 0000000..b312e90 --- /dev/null +++ b/boolvalidator/also_requires_example_test.go @@ -0,0 +1,28 @@ +package boolvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/boolvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleAlsoRequires() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.BoolAttribute{ + Optional: true, + Validators: []validator.Bool{ + // Validate this attribute must be configured with other_attr. + boolvalidator.AlsoRequires(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.StringAttribute{ + Optional: true, + }, + }, + } +} diff --git a/boolvalidator/at_least_one_of.go b/boolvalidator/at_least_one_of.go new file mode 100644 index 0000000..8d2c650 --- /dev/null +++ b/boolvalidator/at_least_one_of.go @@ -0,0 +1,24 @@ +package boolvalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// AtLeastOneOf checks that of a set of path.Expression, +// including the attribute this validator is applied to, +// at least one has a non-null value. +// +// This implements the validation logic declaratively within the tfsdk.Schema. +// Refer to [datasourcevalidator.AtLeastOneOf], +// [providervalidator.AtLeastOneOf], or [resourcevalidator.AtLeastOneOf] +// for declaring this type of validation outside the schema definition. +// +// Any relative path.Expression will be resolved using the attribute being +// validated. +func AtLeastOneOf(expressions ...path.Expression) validator.Bool { + return schemavalidator.AtLeastOneOfValidator{ + PathExpressions: expressions, + } +} diff --git a/boolvalidator/at_least_one_of_example_test.go b/boolvalidator/at_least_one_of_example_test.go new file mode 100644 index 0000000..ccf3f50 --- /dev/null +++ b/boolvalidator/at_least_one_of_example_test.go @@ -0,0 +1,28 @@ +package boolvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/boolvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleAtLeastOneOf() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.BoolAttribute{ + Optional: true, + Validators: []validator.Bool{ + // Validate at least this attribute or other_attr should be configured. + boolvalidator.AtLeastOneOf(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.StringAttribute{ + Optional: true, + }, + }, + } +} diff --git a/boolvalidator/conflicts_with.go b/boolvalidator/conflicts_with.go new file mode 100644 index 0000000..7f27e5c --- /dev/null +++ b/boolvalidator/conflicts_with.go @@ -0,0 +1,24 @@ +package boolvalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// ConflictsWith checks that a set of path.Expression, +// including the attribute the validator is applied to, +// do not have a value simultaneously. +// +// This implements the validation logic declaratively within the schema. +// Refer to [datasourcevalidator.Conflicting], +// [providervalidator.Conflicting], or [resourcevalidator.Conflicting] +// for declaring this type of validation outside the schema definition. +// +// Relative path.Expression will be resolved using the attribute being +// validated. +func ConflictsWith(expressions ...path.Expression) validator.Bool { + return schemavalidator.ConflictsWithValidator{ + PathExpressions: expressions, + } +} diff --git a/boolvalidator/conflicts_with_example_test.go b/boolvalidator/conflicts_with_example_test.go new file mode 100644 index 0000000..f2ac373 --- /dev/null +++ b/boolvalidator/conflicts_with_example_test.go @@ -0,0 +1,28 @@ +package boolvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/boolvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleConflictsWith() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.BoolAttribute{ + Optional: true, + Validators: []validator.Bool{ + // Validate this attribute must not be configured with other_attr. + boolvalidator.ConflictsWith(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.StringAttribute{ + Optional: true, + }, + }, + } +} diff --git a/boolvalidator/doc.go b/boolvalidator/doc.go new file mode 100644 index 0000000..836c72a --- /dev/null +++ b/boolvalidator/doc.go @@ -0,0 +1,2 @@ +// Package boolvalidator provides validators for types.Bool attributes. +package boolvalidator diff --git a/boolvalidator/exactly_one_of.go b/boolvalidator/exactly_one_of.go new file mode 100644 index 0000000..5cd6648 --- /dev/null +++ b/boolvalidator/exactly_one_of.go @@ -0,0 +1,25 @@ +package boolvalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// ExactlyOneOf checks that of a set of path.Expression, +// including the attribute the validator is applied to, +// one and only one attribute has a value. +// It will also cause a validation error if none are specified. +// +// This implements the validation logic declaratively within the schema. +// Refer to [datasourcevalidator.ExactlyOneOf], +// [providervalidator.ExactlyOneOf], or [resourcevalidator.ExactlyOneOf] +// for declaring this type of validation outside the schema definition. +// +// Relative path.Expression will be resolved using the attribute being +// validated. +func ExactlyOneOf(expressions ...path.Expression) validator.Bool { + return schemavalidator.ExactlyOneOfValidator{ + PathExpressions: expressions, + } +} diff --git a/boolvalidator/exactly_one_of_example_test.go b/boolvalidator/exactly_one_of_example_test.go new file mode 100644 index 0000000..9b08f05 --- /dev/null +++ b/boolvalidator/exactly_one_of_example_test.go @@ -0,0 +1,28 @@ +package boolvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/boolvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleExactlyOneOf() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.BoolAttribute{ + Optional: true, + Validators: []validator.Bool{ + // Validate only this attribute or other_attr is configured. + boolvalidator.ExactlyOneOf(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.StringAttribute{ + Optional: true, + }, + }, + } +} diff --git a/datasourcevalidator/at_least_one_of_test.go b/datasourcevalidator/at_least_one_of_test.go index 6a7bc65..52b6f17 100644 --- a/datasourcevalidator/at_least_one_of_test.go +++ b/datasourcevalidator/at_least_one_of_test.go @@ -7,10 +7,10 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework-validators/datasourcevalidator" "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/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-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" ) @@ -28,15 +28,13 @@ func TestAtLeastOneOf(t *testing.T) { }, req: datasource.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -63,19 +61,16 @@ func TestAtLeastOneOf(t *testing.T) { }, req: datasource.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, diff --git a/datasourcevalidator/conflicting_test.go b/datasourcevalidator/conflicting_test.go index 5e0f552..235b3da 100644 --- a/datasourcevalidator/conflicting_test.go +++ b/datasourcevalidator/conflicting_test.go @@ -7,10 +7,10 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework-validators/datasourcevalidator" "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/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-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" ) @@ -28,15 +28,13 @@ func TestConflicting(t *testing.T) { }, req: datasource.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -63,19 +61,16 @@ func TestConflicting(t *testing.T) { }, req: datasource.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, diff --git a/datasourcevalidator/exactly_one_of_test.go b/datasourcevalidator/exactly_one_of_test.go index 4fbaf42..542c157 100644 --- a/datasourcevalidator/exactly_one_of_test.go +++ b/datasourcevalidator/exactly_one_of_test.go @@ -7,10 +7,10 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework-validators/datasourcevalidator" "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/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-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" ) @@ -28,15 +28,13 @@ func TestExactlyOneOf(t *testing.T) { }, req: datasource.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -63,19 +61,16 @@ func TestExactlyOneOf(t *testing.T) { }, req: datasource.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, diff --git a/datasourcevalidator/required_together_test.go b/datasourcevalidator/required_together_test.go index c0b465e..6545446 100644 --- a/datasourcevalidator/required_together_test.go +++ b/datasourcevalidator/required_together_test.go @@ -7,10 +7,10 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework-validators/datasourcevalidator" "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/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-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" ) @@ -28,15 +28,13 @@ func TestRequiredTogether(t *testing.T) { }, req: datasource.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -63,19 +61,16 @@ func TestRequiredTogether(t *testing.T) { }, req: datasource.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, diff --git a/float64validator/all.go b/float64validator/all.go new file mode 100644 index 0000000..6767fd3 --- /dev/null +++ b/float64validator/all.go @@ -0,0 +1,54 @@ +package float64validator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// All returns a validator which ensures that any configured attribute value +// 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 ...validator.Float64) validator.Float64 { + return allValidator{ + validators: validators, + } +} + +var _ validator.Float64 = allValidator{} + +// allValidator implements the validator. +type allValidator struct { + validators []validator.Float64 +} + +// 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) +} + +// ValidateFloat64 performs the validation. +func (v allValidator) ValidateFloat64(ctx context.Context, req validator.Float64Request, resp *validator.Float64Response) { + for _, subValidator := range v.validators { + validateResp := &validator.Float64Response{} + + subValidator.ValidateFloat64(ctx, req, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) + } +} diff --git a/float64validator/all_example_test.go b/float64validator/all_example_test.go new file mode 100644 index 0000000..ad99180 --- /dev/null +++ b/float64validator/all_example_test.go @@ -0,0 +1,30 @@ +package float64validator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/float64validator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleAll() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Float64Attribute{ + Required: true, + Validators: []validator.Float64{ + // Validate this Float64 value must either be: + // - 1.0 + // - At least 2.0, but not 3.0 + float64validator.Any( + float64validator.OneOf(1.0), + float64validator.All( + float64validator.AtLeast(2.0), + float64validator.NoneOf(3.0), + ), + ), + }, + }, + }, + } +} diff --git a/float64validator/all_test.go b/float64validator/all_test.go new file mode 100644 index 0000000..6b8ee1c --- /dev/null +++ b/float64validator/all_test.go @@ -0,0 +1,70 @@ +package float64validator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/float64validator" +) + +func TestAllValidatorValidateFloat64(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.Float64 + validators []validator.Float64 + expected diag.Diagnostics + } + tests := map[string]testCase{ + "invalid": { + val: types.Float64Value(1.2), + validators: []validator.Float64{ + float64validator.AtLeast(3), + float64validator.AtLeast(5), + }, + expected: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid Attribute Value", + "Attribute test value must be at least 3.000000, got: 1.200000", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid Attribute Value", + "Attribute test value must be at least 5.000000, got: 1.200000", + ), + }, + }, + "valid": { + val: types.Float64Value(1.2), + validators: []validator.Float64{ + float64validator.AtLeast(0), + float64validator.AtLeast(1), + }, + expected: nil, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + request := validator.Float64Request{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + } + response := validator.Float64Response{} + float64validator.All(test.validators...).ValidateFloat64(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, test.expected); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/float64validator/also_requires.go b/float64validator/also_requires.go new file mode 100644 index 0000000..8f14977 --- /dev/null +++ b/float64validator/also_requires.go @@ -0,0 +1,23 @@ +package float64validator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// AlsoRequires checks that a set of path.Expression has a non-null value, +// if the current attribute also has a non-null value. +// +// This implements the validation logic declaratively within the schema. +// Refer to [datasourcevalidator.RequiredTogether], +// [providervalidator.RequiredTogether], or [resourcevalidator.RequiredTogether] +// for declaring this type of validation outside the schema definition. +// +// Relative path.Expression will be resolved using the attribute being +// validated. +func AlsoRequires(expressions ...path.Expression) validator.Float64 { + return schemavalidator.AlsoRequiresValidator{ + PathExpressions: expressions, + } +} diff --git a/float64validator/also_requires_example_test.go b/float64validator/also_requires_example_test.go new file mode 100644 index 0000000..5bddff0 --- /dev/null +++ b/float64validator/also_requires_example_test.go @@ -0,0 +1,28 @@ +package float64validator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/float64validator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleAlsoRequires() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Float64Attribute{ + Optional: true, + Validators: []validator.Float64{ + // Validate this attribute must be configured with other_attr. + float64validator.AlsoRequires(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.StringAttribute{ + Optional: true, + }, + }, + } +} diff --git a/float64validator/any.go b/float64validator/any.go new file mode 100644 index 0000000..ed862d7 --- /dev/null +++ b/float64validator/any.go @@ -0,0 +1,62 @@ +package float64validator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// 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 ...validator.Float64) validator.Float64 { + return anyValidator{ + validators: validators, + } +} + +var _ validator.Float64 = anyValidator{} + +// anyValidator implements the validator. +type anyValidator struct { + validators []validator.Float64 +} + +// 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) +} + +// ValidateFloat64 performs the validation. +func (v anyValidator) ValidateFloat64(ctx context.Context, req validator.Float64Request, resp *validator.Float64Response) { + for _, subValidator := range v.validators { + validateResp := &validator.Float64Response{} + + subValidator.ValidateFloat64(ctx, req, validateResp) + + if !validateResp.Diagnostics.HasError() { + resp.Diagnostics = validateResp.Diagnostics + + return + } + + resp.Diagnostics.Append(validateResp.Diagnostics...) + } +} diff --git a/float64validator/any_example_test.go b/float64validator/any_example_test.go new file mode 100644 index 0000000..2c1e406 --- /dev/null +++ b/float64validator/any_example_test.go @@ -0,0 +1,27 @@ +package float64validator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/float64validator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleAny() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Float64Attribute{ + Required: true, + Validators: []validator.Float64{ + // Validate this Float64 value must either be: + // - 1.0 + // - At least 2.0 + float64validator.Any( + float64validator.OneOf(1.0), + float64validator.AtLeast(2.0), + ), + }, + }, + }, + } +} diff --git a/float64validator/any_test.go b/float64validator/any_test.go new file mode 100644 index 0000000..2963c16 --- /dev/null +++ b/float64validator/any_test.go @@ -0,0 +1,81 @@ +package float64validator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/float64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/internal/testvalidator" +) + +func TestAnyValidatorValidateFloat64(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.Float64 + validators []validator.Float64 + expected diag.Diagnostics + } + tests := map[string]testCase{ + "invalid": { + val: types.Float64Value(1.2), + validators: []validator.Float64{ + float64validator.AtLeast(3), + float64validator.AtLeast(5), + }, + expected: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid Attribute Value", + "Attribute test value must be at least 3.000000, got: 1.200000", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid Attribute Value", + "Attribute test value must be at least 5.000000, got: 1.200000", + ), + }, + }, + "valid": { + val: types.Float64Value(4), + validators: []validator.Float64{ + float64validator.AtLeast(5), + float64validator.AtLeast(3), + }, + expected: diag.Diagnostics{}, + }, + "valid with warning": { + val: types.Float64Value(4), + validators: []validator.Float64{ + float64validator.All(float64validator.AtLeast(5), testvalidator.WarningFloat64("failing warning summary", "failing warning details")), + float64validator.All(float64validator.AtLeast(2), testvalidator.WarningFloat64("passing warning summary", "passing warning details")), + }, + expected: diag.Diagnostics{ + diag.NewWarningDiagnostic("passing warning summary", "passing warning details"), + }, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + request := validator.Float64Request{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + } + response := validator.Float64Response{} + float64validator.Any(test.validators...).ValidateFloat64(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, test.expected); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/float64validator/any_with_all_warnings.go b/float64validator/any_with_all_warnings.go new file mode 100644 index 0000000..f4b8ddc --- /dev/null +++ b/float64validator/any_with_all_warnings.go @@ -0,0 +1,64 @@ +package float64validator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// 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 ...validator.Float64) validator.Float64 { + return anyWithAllWarningsValidator{ + validators: validators, + } +} + +var _ validator.Float64 = anyWithAllWarningsValidator{} + +// anyWithAllWarningsValidator implements the validator. +type anyWithAllWarningsValidator struct { + validators []validator.Float64 +} + +// 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) +} + +// ValidateFloat64 performs the validation. +func (v anyWithAllWarningsValidator) ValidateFloat64(ctx context.Context, req validator.Float64Request, resp *validator.Float64Response) { + anyValid := false + + for _, subValidator := range v.validators { + validateResp := &validator.Float64Response{} + + subValidator.ValidateFloat64(ctx, req, validateResp) + + if !validateResp.Diagnostics.HasError() { + anyValid = true + } + + resp.Diagnostics.Append(validateResp.Diagnostics...) + } + + if anyValid { + resp.Diagnostics = resp.Diagnostics.Warnings() + } +} diff --git a/float64validator/any_with_all_warnings_example_test.go b/float64validator/any_with_all_warnings_example_test.go new file mode 100644 index 0000000..04ee87f --- /dev/null +++ b/float64validator/any_with_all_warnings_example_test.go @@ -0,0 +1,27 @@ +package float64validator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/float64validator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleAnyWithAllWarnings() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Float64Attribute{ + Required: true, + Validators: []validator.Float64{ + // Validate this Float64 value must either be: + // - 1.0 + // - At least 2.0 + float64validator.AnyWithAllWarnings( + float64validator.OneOf(1.0), + float64validator.AtLeast(2.0), + ), + }, + }, + }, + } +} diff --git a/float64validator/any_with_all_warnings_test.go b/float64validator/any_with_all_warnings_test.go new file mode 100644 index 0000000..f733ca1 --- /dev/null +++ b/float64validator/any_with_all_warnings_test.go @@ -0,0 +1,82 @@ +package float64validator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/float64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/internal/testvalidator" +) + +func TestAnyWithAllWarningsValidatorValidateFloat64(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.Float64 + validators []validator.Float64 + expected diag.Diagnostics + } + tests := map[string]testCase{ + "invalid": { + val: types.Float64Value(1.2), + validators: []validator.Float64{ + float64validator.AtLeast(3), + float64validator.AtLeast(5), + }, + expected: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid Attribute Value", + "Attribute test value must be at least 3.000000, got: 1.200000", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid Attribute Value", + "Attribute test value must be at least 5.000000, got: 1.200000", + ), + }, + }, + "valid": { + val: types.Float64Value(4), + validators: []validator.Float64{ + float64validator.AtLeast(5), + float64validator.AtLeast(3), + }, + expected: diag.Diagnostics{}, + }, + "valid with warning": { + val: types.Float64Value(4), + validators: []validator.Float64{ + float64validator.All(float64validator.AtLeast(5), testvalidator.WarningFloat64("failing warning summary", "failing warning details")), + float64validator.All(float64validator.AtLeast(2), testvalidator.WarningFloat64("passing warning summary", "passing warning details")), + }, + expected: diag.Diagnostics{ + diag.NewWarningDiagnostic("failing warning summary", "failing warning details"), + diag.NewWarningDiagnostic("passing warning summary", "passing warning details"), + }, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + request := validator.Float64Request{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + } + response := validator.Float64Response{} + float64validator.AnyWithAllWarnings(test.validators...).ValidateFloat64(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, test.expected); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/float64validator/at_least.go b/float64validator/at_least.go index c573f24..2108b36 100644 --- a/float64validator/at_least.go +++ b/float64validator/at_least.go @@ -4,12 +4,12 @@ import ( "context" "fmt" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" ) -var _ tfsdk.AttributeValidator = atLeastValidator{} +var _ validator.Float64 = atLeastValidator{} // atLeastValidator validates that an float Attribute's value is at least a certain value. type atLeastValidator struct { @@ -26,22 +26,20 @@ func (validator atLeastValidator) MarkdownDescription(ctx context.Context) strin return validator.Description(ctx) } -// Validate performs the validation. -func (validator atLeastValidator) Validate(ctx context.Context, request tfsdk.ValidateAttributeRequest, response *tfsdk.ValidateAttributeResponse) { - f, ok := validateFloat(ctx, request, response) - - if !ok { +// ValidateFloat64 performs the validation. +func (validator atLeastValidator) ValidateFloat64(ctx context.Context, request validator.Float64Request, response *validator.Float64Response) { + if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() { return } - if f < validator.min { + value := request.ConfigValue.ValueFloat64() + + if value < validator.min { response.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( - request.AttributePath, + request.Path, validator.Description(ctx), - fmt.Sprintf("%f", f), + fmt.Sprintf("%f", value), )) - - return } } @@ -52,7 +50,7 @@ func (validator atLeastValidator) Validate(ctx context.Context, request tfsdk.Va // - Is greater than or equal to the given minimum. // // Null (unconfigured) and unknown (known after apply) values are skipped. -func AtLeast(min float64) tfsdk.AttributeValidator { +func AtLeast(min float64) validator.Float64 { return atLeastValidator{ min: min, } diff --git a/float64validator/at_least_example_test.go b/float64validator/at_least_example_test.go index 5a34884..f8399b3 100644 --- a/float64validator/at_least_example_test.go +++ b/float64validator/at_least_example_test.go @@ -2,18 +2,17 @@ package float64validator_test import ( "github.com/hashicorp/terraform-plugin-framework-validators/float64validator" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" ) func ExampleAtLeast() { - // Used within a GetSchema method of a DataSource, Provider, or Resource - _ = tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "example_attr": { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Float64Attribute{ Required: true, - Type: types.Float64Type, - Validators: []tfsdk.AttributeValidator{ + Validators: []validator.Float64{ // Validate floating point value must be at least 42.42 float64validator.AtLeast(42.42), }, diff --git a/float64validator/at_least_one_of.go b/float64validator/at_least_one_of.go new file mode 100644 index 0000000..a30e347 --- /dev/null +++ b/float64validator/at_least_one_of.go @@ -0,0 +1,24 @@ +package float64validator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// AtLeastOneOf checks that of a set of path.Expression, +// including the attribute this validator is applied to, +// at least one has a non-null value. +// +// This implements the validation logic declaratively within the tfsdk.Schema. +// Refer to [datasourcevalidator.AtLeastOneOf], +// [providervalidator.AtLeastOneOf], or [resourcevalidator.AtLeastOneOf] +// for declaring this type of validation outside the schema definition. +// +// Any relative path.Expression will be resolved using the attribute being +// validated. +func AtLeastOneOf(expressions ...path.Expression) validator.Float64 { + return schemavalidator.AtLeastOneOfValidator{ + PathExpressions: expressions, + } +} diff --git a/float64validator/at_least_one_of_example_test.go b/float64validator/at_least_one_of_example_test.go new file mode 100644 index 0000000..7a67e99 --- /dev/null +++ b/float64validator/at_least_one_of_example_test.go @@ -0,0 +1,28 @@ +package float64validator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/float64validator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleAtLeastOneOf() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Float64Attribute{ + Optional: true, + Validators: []validator.Float64{ + // Validate at least this attribute or other_attr should be configured. + float64validator.AtLeastOneOf(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.StringAttribute{ + Optional: true, + }, + }, + } +} diff --git a/float64validator/at_least_test.go b/float64validator/at_least_test.go index 4c0e85a..0169ffa 100644 --- a/float64validator/at_least_test.go +++ b/float64validator/at_least_test.go @@ -4,9 +4,8 @@ import ( "context" "testing" - "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework-validators/float64validator" @@ -16,15 +15,11 @@ func TestAtLeastValidator(t *testing.T) { t.Parallel() type testCase struct { - val attr.Value + val types.Float64 min float64 expectError bool } tests := map[string]testCase{ - "not a Float64": { - val: types.BoolValue(true), - expectError: true, - }, "unknown Float64": { val: types.Float64Unknown(), min: 0.90, @@ -55,13 +50,13 @@ func TestAtLeastValidator(t *testing.T) { for name, test := range tests { name, test := name, test t.Run(name, func(t *testing.T) { - request := tfsdk.ValidateAttributeRequest{ - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - AttributeConfig: test.val, + request := validator.Float64Request{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, } - response := tfsdk.ValidateAttributeResponse{} - float64validator.AtLeast(test.min).Validate(context.TODO(), request, &response) + response := validator.Float64Response{} + float64validator.AtLeast(test.min).ValidateFloat64(context.TODO(), request, &response) if !response.Diagnostics.HasError() && test.expectError { t.Fatal("expected error, got no error") diff --git a/float64validator/at_most.go b/float64validator/at_most.go index e7d3a55..4b0c764 100644 --- a/float64validator/at_most.go +++ b/float64validator/at_most.go @@ -4,12 +4,12 @@ import ( "context" "fmt" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" ) -var _ tfsdk.AttributeValidator = atMostValidator{} +var _ validator.Float64 = atMostValidator{} // atMostValidator validates that an float Attribute's value is at most a certain value. type atMostValidator struct { @@ -26,22 +26,20 @@ func (validator atMostValidator) MarkdownDescription(ctx context.Context) string return validator.Description(ctx) } -// Validate performs the validation. -func (validator atMostValidator) Validate(ctx context.Context, request tfsdk.ValidateAttributeRequest, response *tfsdk.ValidateAttributeResponse) { - f, ok := validateFloat(ctx, request, response) - - if !ok { +// ValidateFloat64 performs the validation. +func (v atMostValidator) ValidateFloat64(ctx context.Context, request validator.Float64Request, response *validator.Float64Response) { + if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() { return } - if f > validator.max { + value := request.ConfigValue.ValueFloat64() + + if value > v.max { response.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( - request.AttributePath, - validator.Description(ctx), - fmt.Sprintf("%f", f), + request.Path, + v.Description(ctx), + fmt.Sprintf("%f", value), )) - - return } } @@ -52,7 +50,7 @@ func (validator atMostValidator) Validate(ctx context.Context, request tfsdk.Val // - Is less than or equal to the given maximum. // // Null (unconfigured) and unknown (known after apply) values are skipped. -func AtMost(max float64) tfsdk.AttributeValidator { +func AtMost(max float64) validator.Float64 { return atMostValidator{ max: max, } diff --git a/float64validator/at_most_example_test.go b/float64validator/at_most_example_test.go index 409c05a..9b63a67 100644 --- a/float64validator/at_most_example_test.go +++ b/float64validator/at_most_example_test.go @@ -2,18 +2,17 @@ package float64validator_test import ( "github.com/hashicorp/terraform-plugin-framework-validators/float64validator" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" ) func ExampleAtMost() { - // Used within a GetSchema method of a DataSource, Provider, or Resource - _ = tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "example_attr": { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Float64Attribute{ Required: true, - Type: types.Float64Type, - Validators: []tfsdk.AttributeValidator{ + Validators: []validator.Float64{ // Validate floating point value must be at most 42.42 float64validator.AtMost(42.42), }, diff --git a/float64validator/at_most_test.go b/float64validator/at_most_test.go index 80f4ea7..16a4048 100644 --- a/float64validator/at_most_test.go +++ b/float64validator/at_most_test.go @@ -4,9 +4,8 @@ import ( "context" "testing" - "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework-validators/float64validator" @@ -16,15 +15,11 @@ func TestAtMostValidator(t *testing.T) { t.Parallel() type testCase struct { - val attr.Value + val types.Float64 max float64 expectError bool } tests := map[string]testCase{ - "not a Float64": { - val: types.BoolValue(true), - expectError: true, - }, "unknown Float64": { val: types.Float64Unknown(), max: 2.00, @@ -55,13 +50,13 @@ func TestAtMostValidator(t *testing.T) { for name, test := range tests { name, test := name, test t.Run(name, func(t *testing.T) { - request := tfsdk.ValidateAttributeRequest{ - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - AttributeConfig: test.val, + request := validator.Float64Request{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, } - response := tfsdk.ValidateAttributeResponse{} - float64validator.AtMost(test.max).Validate(context.TODO(), request, &response) + response := validator.Float64Response{} + float64validator.AtMost(test.max).ValidateFloat64(context.TODO(), request, &response) if !response.Diagnostics.HasError() && test.expectError { t.Fatal("expected error, got no error") diff --git a/float64validator/between.go b/float64validator/between.go index 6747408..d590435 100644 --- a/float64validator/between.go +++ b/float64validator/between.go @@ -4,12 +4,12 @@ import ( "context" "fmt" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" ) -var _ tfsdk.AttributeValidator = betweenValidator{} +var _ validator.Float64 = betweenValidator{} // betweenValidator validates that an float Attribute's value is in a range. type betweenValidator struct { @@ -26,22 +26,20 @@ func (validator betweenValidator) MarkdownDescription(ctx context.Context) strin return validator.Description(ctx) } -// Validate performs the validation. -func (validator betweenValidator) Validate(ctx context.Context, request tfsdk.ValidateAttributeRequest, response *tfsdk.ValidateAttributeResponse) { - f, ok := validateFloat(ctx, request, response) - - if !ok { +// ValidateFloat64 performs the validation. +func (v betweenValidator) ValidateFloat64(ctx context.Context, request validator.Float64Request, response *validator.Float64Response) { + if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() { return } - if f < validator.min || f > validator.max { + value := request.ConfigValue.ValueFloat64() + + if value < v.min || value > v.max { response.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( - request.AttributePath, - validator.Description(ctx), - fmt.Sprintf("%f", f), + request.Path, + v.Description(ctx), + fmt.Sprintf("%f", value), )) - - return } } @@ -52,7 +50,7 @@ func (validator betweenValidator) Validate(ctx context.Context, request tfsdk.Va // - Is greater than or equal to the given minimum and less than or equal to the given maximum. // // Null (unconfigured) and unknown (known after apply) values are skipped. -func Between(min, max float64) tfsdk.AttributeValidator { +func Between(min, max float64) validator.Float64 { if min > max { return nil } diff --git a/float64validator/between_example_test.go b/float64validator/between_example_test.go index 8621374..303ac08 100644 --- a/float64validator/between_example_test.go +++ b/float64validator/between_example_test.go @@ -2,18 +2,17 @@ package float64validator_test import ( "github.com/hashicorp/terraform-plugin-framework-validators/float64validator" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" ) func ExampleBetween() { - // Used within a GetSchema method of a DataSource, Provider, or Resource - _ = tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "example_attr": { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Float64Attribute{ Required: true, - Type: types.Float64Type, - Validators: []tfsdk.AttributeValidator{ + Validators: []validator.Float64{ // Validate floating point value must be at least 0.0 and at most 1.0 float64validator.Between(0.0, 1.0), }, diff --git a/float64validator/between_test.go b/float64validator/between_test.go index 82c4dee..a369542 100644 --- a/float64validator/between_test.go +++ b/float64validator/between_test.go @@ -4,9 +4,8 @@ import ( "context" "testing" - "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework-validators/float64validator" @@ -16,16 +15,12 @@ func TestBetweenValidator(t *testing.T) { t.Parallel() type testCase struct { - val attr.Value + val types.Float64 min float64 max float64 expectError bool } tests := map[string]testCase{ - "not a Float64": { - val: types.BoolValue(true), - expectError: true, - }, "unknown Float64": { val: types.Float64Unknown(), min: 0.90, @@ -73,13 +68,13 @@ func TestBetweenValidator(t *testing.T) { for name, test := range tests { name, test := name, test t.Run(name, func(t *testing.T) { - request := tfsdk.ValidateAttributeRequest{ - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - AttributeConfig: test.val, + request := validator.Float64Request{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, } - response := tfsdk.ValidateAttributeResponse{} - float64validator.Between(test.min, test.max).Validate(context.TODO(), request, &response) + response := validator.Float64Response{} + float64validator.Between(test.min, test.max).ValidateFloat64(context.TODO(), request, &response) if !response.Diagnostics.HasError() && test.expectError { t.Fatal("expected error, got no error") diff --git a/float64validator/conflicts_with.go b/float64validator/conflicts_with.go new file mode 100644 index 0000000..7251868 --- /dev/null +++ b/float64validator/conflicts_with.go @@ -0,0 +1,24 @@ +package float64validator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// ConflictsWith checks that a set of path.Expression, +// including the attribute the validator is applied to, +// do not have a value simultaneously. +// +// This implements the validation logic declaratively within the schema. +// Refer to [datasourcevalidator.Conflicting], +// [providervalidator.Conflicting], or [resourcevalidator.Conflicting] +// for declaring this type of validation outside the schema definition. +// +// Relative path.Expression will be resolved using the attribute being +// validated. +func ConflictsWith(expressions ...path.Expression) validator.Float64 { + return schemavalidator.ConflictsWithValidator{ + PathExpressions: expressions, + } +} diff --git a/float64validator/conflicts_with_example_test.go b/float64validator/conflicts_with_example_test.go new file mode 100644 index 0000000..e8b1067 --- /dev/null +++ b/float64validator/conflicts_with_example_test.go @@ -0,0 +1,28 @@ +package float64validator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/float64validator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleConflictsWith() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Float64Attribute{ + Optional: true, + Validators: []validator.Float64{ + // Validate this attribute must not be configured with other_attr. + float64validator.ConflictsWith(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.StringAttribute{ + Optional: true, + }, + }, + } +} diff --git a/float64validator/exactly_one_of.go b/float64validator/exactly_one_of.go new file mode 100644 index 0000000..55fff9c --- /dev/null +++ b/float64validator/exactly_one_of.go @@ -0,0 +1,25 @@ +package float64validator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// ExactlyOneOf checks that of a set of path.Expression, +// including the attribute the validator is applied to, +// one and only one attribute has a value. +// It will also cause a validation error if none are specified. +// +// This implements the validation logic declaratively within the schema. +// Refer to [datasourcevalidator.ExactlyOneOf], +// [providervalidator.ExactlyOneOf], or [resourcevalidator.ExactlyOneOf] +// for declaring this type of validation outside the schema definition. +// +// Relative path.Expression will be resolved using the attribute being +// validated. +func ExactlyOneOf(expressions ...path.Expression) validator.Float64 { + return schemavalidator.ExactlyOneOfValidator{ + PathExpressions: expressions, + } +} diff --git a/float64validator/exactly_one_of_example_test.go b/float64validator/exactly_one_of_example_test.go new file mode 100644 index 0000000..9c59190 --- /dev/null +++ b/float64validator/exactly_one_of_example_test.go @@ -0,0 +1,28 @@ +package float64validator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/float64validator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleExactlyOneOf() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Float64Attribute{ + Optional: true, + Validators: []validator.Float64{ + // Validate only this attribute or other_attr is configured. + float64validator.ExactlyOneOf(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.StringAttribute{ + Optional: true, + }, + }, + } +} diff --git a/float64validator/none_of.go b/float64validator/none_of.go index ffb6999..92bcdc9 100644 --- a/float64validator/none_of.go +++ b/float64validator/none_of.go @@ -1,20 +1,62 @@ package float64validator import ( - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" - "github.com/hashicorp/terraform-plugin-framework-validators/internal/primitivevalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" ) +var _ validator.Float64 = noneOfValidator{} + +// noneOfValidator validates that the value does not match one of the values. +type noneOfValidator struct { + values []types.Float64 +} + +func (v noneOfValidator) Description(ctx context.Context) string { + return v.MarkdownDescription(ctx) +} + +func (v noneOfValidator) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("value must be none of: %q", v.values) +} + +func (v noneOfValidator) ValidateFloat64(ctx context.Context, request validator.Float64Request, response *validator.Float64Response) { + if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() { + return + } + + value := request.ConfigValue + + for _, otherValue := range v.values { + if !value.Equal(otherValue) { + continue + } + + response.Diagnostics.Append(validatordiag.InvalidAttributeValueMatchDiagnostic( + request.Path, + v.Description(ctx), + value.String(), + )) + + break + } +} + // NoneOf checks that the float64 held in the attribute -// is none of the given `unacceptableFloats`. -func NoneOf(unacceptableFloats ...float64) tfsdk.AttributeValidator { - unacceptableFloatValues := make([]attr.Value, 0, len(unacceptableFloats)) - for _, f := range unacceptableFloats { - unacceptableFloatValues = append(unacceptableFloatValues, types.Float64Value(f)) +// is none of the given `values`. +func NoneOf(values ...float64) validator.Float64 { + frameworkValues := make([]types.Float64, 0, len(values)) + + for _, value := range values { + frameworkValues = append(frameworkValues, types.Float64Value(value)) } - return primitivevalidator.NoneOf(unacceptableFloatValues...) + return noneOfValidator{ + values: frameworkValues, + } } diff --git a/float64validator/none_of_example_test.go b/float64validator/none_of_example_test.go index e571628..ea7295a 100644 --- a/float64validator/none_of_example_test.go +++ b/float64validator/none_of_example_test.go @@ -2,18 +2,17 @@ package float64validator_test import ( "github.com/hashicorp/terraform-plugin-framework-validators/float64validator" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" ) func ExampleNoneOf() { - // Used within a GetSchema method of a DataSource, Provider, or Resource - _ = tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "example_attr": { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Float64Attribute{ Required: true, - Type: types.Float64Type, - Validators: []tfsdk.AttributeValidator{ + Validators: []validator.Float64{ // Validate floating point value must not be 1.2, 2.4, or 4.8 float64validator.NoneOf([]float64{1.2, 2.4, 4.8}...), }, diff --git a/float64validator/none_of_test.go b/float64validator/none_of_test.go index 62a121f..8298a2a 100644 --- a/float64validator/none_of_test.go +++ b/float64validator/none_of_test.go @@ -4,8 +4,7 @@ import ( "context" "testing" - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework-validators/float64validator" @@ -15,8 +14,8 @@ func TestNoneOfValidator(t *testing.T) { t.Parallel() type testCase struct { - in attr.Value - validator tfsdk.AttributeValidator + in types.Float64 + validator validator.Float64 expErrors int } @@ -63,11 +62,11 @@ func TestNoneOfValidator(t *testing.T) { for name, test := range testCases { name, test := name, test t.Run(name, func(t *testing.T) { - req := tfsdk.ValidateAttributeRequest{ - AttributeConfig: test.in, + req := validator.Float64Request{ + ConfigValue: test.in, } - res := tfsdk.ValidateAttributeResponse{} - test.validator.Validate(context.TODO(), req, &res) + res := validator.Float64Response{} + test.validator.ValidateFloat64(context.TODO(), req, &res) if test.expErrors > 0 && !res.Diagnostics.HasError() { t.Fatalf("expected %d error(s), got none", test.expErrors) diff --git a/float64validator/one_of.go b/float64validator/one_of.go index e4340db..f34492d 100644 --- a/float64validator/one_of.go +++ b/float64validator/one_of.go @@ -1,20 +1,60 @@ package float64validator import ( - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" - "github.com/hashicorp/terraform-plugin-framework-validators/internal/primitivevalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" ) +var _ validator.Float64 = oneOfValidator{} + +// oneOfValidator validates that the value matches one of expected values. +type oneOfValidator struct { + values []types.Float64 +} + +func (v oneOfValidator) Description(ctx context.Context) string { + return v.MarkdownDescription(ctx) +} + +func (v oneOfValidator) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("value must be one of: %q", v.values) +} + +func (v oneOfValidator) ValidateFloat64(ctx context.Context, request validator.Float64Request, response *validator.Float64Response) { + if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() { + return + } + + value := request.ConfigValue + + for _, otherValue := range v.values { + if value.Equal(otherValue) { + return + } + } + + response.Diagnostics.Append(validatordiag.InvalidAttributeValueMatchDiagnostic( + request.Path, + v.Description(ctx), + value.String(), + )) +} + // OneOf checks that the float64 held in the attribute -// is one of the given `acceptableFloats`. -func OneOf(acceptableFloats ...float64) tfsdk.AttributeValidator { - acceptableFloatValues := make([]attr.Value, 0, len(acceptableFloats)) - for _, f := range acceptableFloats { - acceptableFloatValues = append(acceptableFloatValues, types.Float64Value(f)) +// is none of the given `values`. +func OneOf(values ...float64) validator.Float64 { + frameworkValues := make([]types.Float64, 0, len(values)) + + for _, value := range values { + frameworkValues = append(frameworkValues, types.Float64Value(value)) } - return primitivevalidator.OneOf(acceptableFloatValues...) + return oneOfValidator{ + values: frameworkValues, + } } diff --git a/float64validator/one_of_example_test.go b/float64validator/one_of_example_test.go index fba75b7..f8a5b6a 100644 --- a/float64validator/one_of_example_test.go +++ b/float64validator/one_of_example_test.go @@ -2,18 +2,17 @@ package float64validator_test import ( "github.com/hashicorp/terraform-plugin-framework-validators/float64validator" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" ) func ExampleOneOf() { - // Used within a GetSchema method of a DataSource, Provider, or Resource - _ = tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "example_attr": { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Float64Attribute{ Required: true, - Type: types.Float64Type, - Validators: []tfsdk.AttributeValidator{ + Validators: []validator.Float64{ // Validate floating point value must be 1.2, 2.4, or 4.8 float64validator.OneOf([]float64{1.2, 2.4, 4.8}...), }, diff --git a/float64validator/one_of_test.go b/float64validator/one_of_test.go index 0e03f8e..323ac9f 100644 --- a/float64validator/one_of_test.go +++ b/float64validator/one_of_test.go @@ -4,8 +4,7 @@ import ( "context" "testing" - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework-validators/float64validator" @@ -15,8 +14,8 @@ func TestOneOfValidator(t *testing.T) { t.Parallel() type testCase struct { - in attr.Value - validator tfsdk.AttributeValidator + in types.Float64 + validator validator.Float64 expErrors int } @@ -63,11 +62,11 @@ func TestOneOfValidator(t *testing.T) { for name, test := range testCases { name, test := name, test t.Run(name, func(t *testing.T) { - req := tfsdk.ValidateAttributeRequest{ - AttributeConfig: test.in, + req := validator.Float64Request{ + ConfigValue: test.in, } - res := tfsdk.ValidateAttributeResponse{} - test.validator.Validate(context.TODO(), req, &res) + res := validator.Float64Response{} + test.validator.ValidateFloat64(context.TODO(), req, &res) if test.expErrors > 0 && !res.Diagnostics.HasError() { t.Fatalf("expected %d error(s), got none", test.expErrors) diff --git a/float64validator/type_validation.go b/float64validator/type_validation.go deleted file mode 100644 index d9fe537..0000000 --- a/float64validator/type_validation.go +++ /dev/null @@ -1,30 +0,0 @@ -package float64validator - -import ( - "context" - - "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" -) - -// validateFloat ensures that the request contains a Float64 value. -func validateFloat(ctx context.Context, request tfsdk.ValidateAttributeRequest, response *tfsdk.ValidateAttributeResponse) (float64, bool) { - t := request.AttributeConfig.Type(ctx) - if t != types.Float64Type { - response.Diagnostics.Append(validatordiag.InvalidAttributeTypeDiagnostic( - request.AttributePath, - "expected value of type float64", - t.String(), - )) - return 0.0, false - } - - f := request.AttributeConfig.(types.Float64) - - if f.IsUnknown() || f.IsNull() { - return 0.0, false - } - - return f.ValueFloat64(), true -} diff --git a/float64validator/type_validation_test.go b/float64validator/type_validation_test.go deleted file mode 100644 index a589505..0000000 --- a/float64validator/type_validation_test.go +++ /dev/null @@ -1,76 +0,0 @@ -package float64validator - -import ( - "context" - "testing" - - "github.com/google/go-cmp/cmp" - "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" -) - -func TestValidateFloat(t *testing.T) { - t.Parallel() - - testCases := map[string]struct { - request tfsdk.ValidateAttributeRequest - expectedFloat64 float64 - expectedOk bool - }{ - "invalid-type": { - request: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.BoolValue(true), - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - }, - expectedFloat64: 0.0, - expectedOk: false, - }, - "float64-null": { - request: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.Float64Null(), - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - }, - expectedFloat64: 0.0, - expectedOk: false, - }, - "float64-value": { - request: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.Float64Value(1.2), - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - }, - expectedFloat64: 1.2, - expectedOk: true, - }, - "float64-unknown": { - request: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.Float64Unknown(), - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - }, - expectedFloat64: 0.0, - expectedOk: false, - }, - } - - for name, testCase := range testCases { - name, testCase := name, testCase - - t.Run(name, func(t *testing.T) { - t.Parallel() - - gotFloat64, gotOk := validateFloat(context.Background(), testCase.request, &tfsdk.ValidateAttributeResponse{}) - - if diff := cmp.Diff(gotFloat64, testCase.expectedFloat64); diff != "" { - t.Errorf("unexpected float64 difference: %s", diff) - } - - if diff := cmp.Diff(gotOk, testCase.expectedOk); diff != "" { - t.Errorf("unexpected ok difference: %s", diff) - } - }) - } -} diff --git a/go.mod b/go.mod index 8c616c6..e1abc53 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.18 require ( github.com/google/go-cmp v0.5.9 - github.com/hashicorp/terraform-plugin-framework v0.16.0 + github.com/hashicorp/terraform-plugin-framework v0.17.0 github.com/hashicorp/terraform-plugin-go v0.14.2 ) diff --git a/go.sum b/go.sum index 988e737..631dd0b 100644 --- a/go.sum +++ b/go.sum @@ -13,8 +13,8 @@ github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/hashicorp/go-hclog v1.2.1 h1:YQsLlGDJgwhXFpucSPyVbCBviQtjlHv3jLTlp8YmtEw= github.com/hashicorp/go-hclog v1.2.1/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= -github.com/hashicorp/terraform-plugin-framework v0.16.0 h1:kEHh0d6dp5Ig/ey6PYXkWDZPMLIW8Me41T/Oa7bpO4s= -github.com/hashicorp/terraform-plugin-framework v0.16.0/go.mod h1:Vk5MuIJoE1qksHZawAZr6psx6YXsQBFIKDrWbROrwus= +github.com/hashicorp/terraform-plugin-framework v0.17.0 h1:0KUOY/oe1GPLFqaXnKDnd1rhCrnUtt8pV9wGEwNUFlU= +github.com/hashicorp/terraform-plugin-framework v0.17.0/go.mod h1:FV97t2BZOARkL7NNlsc/N25c84MyeSSz72uPp7Vq1lg= github.com/hashicorp/terraform-plugin-go v0.14.2 h1:rhsVEOGCnY04msNymSvbUsXfRLKh9znXZmHlf5e8mhE= github.com/hashicorp/terraform-plugin-go v0.14.2/go.mod h1:Q12UjumPNGiFsZffxOsA40Tlz1WVXt2Evh865Zj0+UA= github.com/hashicorp/terraform-plugin-log v0.7.0 h1:SDxJUyT8TwN4l5b5/VkiTIaQgY6R+Y2BQ0sRZftGKQs= diff --git a/int64validator/all.go b/int64validator/all.go new file mode 100644 index 0000000..cd7fedd --- /dev/null +++ b/int64validator/all.go @@ -0,0 +1,54 @@ +package int64validator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// All returns a validator which ensures that any configured attribute value +// 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 ...validator.Int64) validator.Int64 { + return allValidator{ + validators: validators, + } +} + +var _ validator.Int64 = allValidator{} + +// allValidator implements the validator. +type allValidator struct { + validators []validator.Int64 +} + +// 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) +} + +// ValidateInt64 performs the validation. +func (v allValidator) ValidateInt64(ctx context.Context, req validator.Int64Request, resp *validator.Int64Response) { + for _, subValidator := range v.validators { + validateResp := &validator.Int64Response{} + + subValidator.ValidateInt64(ctx, req, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) + } +} diff --git a/int64validator/all_example_test.go b/int64validator/all_example_test.go new file mode 100644 index 0000000..893fe0a --- /dev/null +++ b/int64validator/all_example_test.go @@ -0,0 +1,30 @@ +package int64validator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleAll() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Int64Attribute{ + Required: true, + Validators: []validator.Int64{ + // Validate this Int64 value must either be: + // - 1.0 + // - At least 2.0, but not 3.0 + int64validator.Any( + int64validator.OneOf(1.0), + int64validator.All( + int64validator.AtLeast(2.0), + int64validator.NoneOf(3.0), + ), + ), + }, + }, + }, + } +} diff --git a/int64validator/all_test.go b/int64validator/all_test.go new file mode 100644 index 0000000..3b14ae7 --- /dev/null +++ b/int64validator/all_test.go @@ -0,0 +1,70 @@ +package int64validator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" +) + +func TestAllValidatorValidateInt64(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.Int64 + validators []validator.Int64 + expected diag.Diagnostics + } + tests := map[string]testCase{ + "invalid": { + val: types.Int64Value(1), + validators: []validator.Int64{ + int64validator.AtLeast(3), + int64validator.AtLeast(5), + }, + expected: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid Attribute Value", + "Attribute test value must be at least 3, got: 1", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid Attribute Value", + "Attribute test value must be at least 5, got: 1", + ), + }, + }, + "valid": { + val: types.Int64Value(1), + validators: []validator.Int64{ + int64validator.AtLeast(0), + int64validator.AtLeast(1), + }, + expected: nil, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + request := validator.Int64Request{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + } + response := validator.Int64Response{} + int64validator.All(test.validators...).ValidateInt64(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, test.expected); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/int64validator/also_requires.go b/int64validator/also_requires.go new file mode 100644 index 0000000..464222a --- /dev/null +++ b/int64validator/also_requires.go @@ -0,0 +1,23 @@ +package int64validator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// AlsoRequires checks that a set of path.Expression has a non-null value, +// if the current attribute also has a non-null value. +// +// This implements the validation logic declaratively within the schema. +// Refer to [datasourcevalidator.RequiredTogether], +// [providervalidator.RequiredTogether], or [resourcevalidator.RequiredTogether] +// for declaring this type of validation outside the schema definition. +// +// Relative path.Expression will be resolved using the attribute being +// validated. +func AlsoRequires(expressions ...path.Expression) validator.Int64 { + return schemavalidator.AlsoRequiresValidator{ + PathExpressions: expressions, + } +} diff --git a/int64validator/also_requires_example_test.go b/int64validator/also_requires_example_test.go new file mode 100644 index 0000000..68857ad --- /dev/null +++ b/int64validator/also_requires_example_test.go @@ -0,0 +1,28 @@ +package int64validator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleAlsoRequires() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Int64Attribute{ + Optional: true, + Validators: []validator.Int64{ + // Validate this attribute must be configured with other_attr. + int64validator.AlsoRequires(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.StringAttribute{ + Optional: true, + }, + }, + } +} diff --git a/int64validator/any.go b/int64validator/any.go new file mode 100644 index 0000000..0f0a400 --- /dev/null +++ b/int64validator/any.go @@ -0,0 +1,62 @@ +package int64validator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// 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 ...validator.Int64) validator.Int64 { + return anyValidator{ + validators: validators, + } +} + +var _ validator.Int64 = anyValidator{} + +// anyValidator implements the validator. +type anyValidator struct { + validators []validator.Int64 +} + +// 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) +} + +// ValidateInt64 performs the validation. +func (v anyValidator) ValidateInt64(ctx context.Context, req validator.Int64Request, resp *validator.Int64Response) { + for _, subValidator := range v.validators { + validateResp := &validator.Int64Response{} + + subValidator.ValidateInt64(ctx, req, validateResp) + + if !validateResp.Diagnostics.HasError() { + resp.Diagnostics = validateResp.Diagnostics + + return + } + + resp.Diagnostics.Append(validateResp.Diagnostics...) + } +} diff --git a/int64validator/any_example_test.go b/int64validator/any_example_test.go new file mode 100644 index 0000000..fc6ed1b --- /dev/null +++ b/int64validator/any_example_test.go @@ -0,0 +1,27 @@ +package int64validator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleAny() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Int64Attribute{ + Required: true, + Validators: []validator.Int64{ + // Validate this Int64 value must either be: + // - 1.0 + // - At least 2.0 + int64validator.Any( + int64validator.OneOf(1.0), + int64validator.AtLeast(2.0), + ), + }, + }, + }, + } +} diff --git a/int64validator/any_test.go b/int64validator/any_test.go new file mode 100644 index 0000000..f56d12f --- /dev/null +++ b/int64validator/any_test.go @@ -0,0 +1,81 @@ +package int64validator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/internal/testvalidator" +) + +func TestAnyValidatorValidateInt64(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.Int64 + validators []validator.Int64 + expected diag.Diagnostics + } + tests := map[string]testCase{ + "invalid": { + val: types.Int64Value(1), + validators: []validator.Int64{ + int64validator.AtLeast(3), + int64validator.AtLeast(5), + }, + expected: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid Attribute Value", + "Attribute test value must be at least 3, got: 1", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid Attribute Value", + "Attribute test value must be at least 5, got: 1", + ), + }, + }, + "valid": { + val: types.Int64Value(4), + validators: []validator.Int64{ + int64validator.AtLeast(5), + int64validator.AtLeast(3), + }, + expected: diag.Diagnostics{}, + }, + "valid with warning": { + val: types.Int64Value(4), + validators: []validator.Int64{ + int64validator.All(int64validator.AtLeast(5), testvalidator.WarningInt64("failing warning summary", "failing warning details")), + int64validator.All(int64validator.AtLeast(2), testvalidator.WarningInt64("passing warning summary", "passing warning details")), + }, + expected: diag.Diagnostics{ + diag.NewWarningDiagnostic("passing warning summary", "passing warning details"), + }, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + request := validator.Int64Request{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + } + response := validator.Int64Response{} + int64validator.Any(test.validators...).ValidateInt64(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, test.expected); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/int64validator/any_with_all_warnings.go b/int64validator/any_with_all_warnings.go new file mode 100644 index 0000000..22ed842 --- /dev/null +++ b/int64validator/any_with_all_warnings.go @@ -0,0 +1,64 @@ +package int64validator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// 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 ...validator.Int64) validator.Int64 { + return anyWithAllWarningsValidator{ + validators: validators, + } +} + +var _ validator.Int64 = anyWithAllWarningsValidator{} + +// anyWithAllWarningsValidator implements the validator. +type anyWithAllWarningsValidator struct { + validators []validator.Int64 +} + +// 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) +} + +// ValidateInt64 performs the validation. +func (v anyWithAllWarningsValidator) ValidateInt64(ctx context.Context, req validator.Int64Request, resp *validator.Int64Response) { + anyValid := false + + for _, subValidator := range v.validators { + validateResp := &validator.Int64Response{} + + subValidator.ValidateInt64(ctx, req, validateResp) + + if !validateResp.Diagnostics.HasError() { + anyValid = true + } + + resp.Diagnostics.Append(validateResp.Diagnostics...) + } + + if anyValid { + resp.Diagnostics = resp.Diagnostics.Warnings() + } +} diff --git a/int64validator/any_with_all_warnings_example_test.go b/int64validator/any_with_all_warnings_example_test.go new file mode 100644 index 0000000..74f812b --- /dev/null +++ b/int64validator/any_with_all_warnings_example_test.go @@ -0,0 +1,27 @@ +package int64validator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleAnyWithAllWarnings() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Int64Attribute{ + Required: true, + Validators: []validator.Int64{ + // Validate this Int64 value must either be: + // - 1.0 + // - At least 2.0 + int64validator.AnyWithAllWarnings( + int64validator.OneOf(1.0), + int64validator.AtLeast(2.0), + ), + }, + }, + }, + } +} diff --git a/int64validator/any_with_all_warnings_test.go b/int64validator/any_with_all_warnings_test.go new file mode 100644 index 0000000..1d95fdb --- /dev/null +++ b/int64validator/any_with_all_warnings_test.go @@ -0,0 +1,82 @@ +package int64validator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/internal/testvalidator" +) + +func TestAnyWithAllWarningsValidatorValidateInt64(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.Int64 + validators []validator.Int64 + expected diag.Diagnostics + } + tests := map[string]testCase{ + "invalid": { + val: types.Int64Value(1), + validators: []validator.Int64{ + int64validator.AtLeast(3), + int64validator.AtLeast(5), + }, + expected: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid Attribute Value", + "Attribute test value must be at least 3, got: 1", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid Attribute Value", + "Attribute test value must be at least 5, got: 1", + ), + }, + }, + "valid": { + val: types.Int64Value(4), + validators: []validator.Int64{ + int64validator.AtLeast(5), + int64validator.AtLeast(3), + }, + expected: diag.Diagnostics{}, + }, + "valid with warning": { + val: types.Int64Value(4), + validators: []validator.Int64{ + int64validator.All(int64validator.AtLeast(5), testvalidator.WarningInt64("failing warning summary", "failing warning details")), + int64validator.All(int64validator.AtLeast(2), testvalidator.WarningInt64("passing warning summary", "passing warning details")), + }, + expected: diag.Diagnostics{ + diag.NewWarningDiagnostic("failing warning summary", "failing warning details"), + diag.NewWarningDiagnostic("passing warning summary", "passing warning details"), + }, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + request := validator.Int64Request{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + } + response := validator.Int64Response{} + int64validator.AnyWithAllWarnings(test.validators...).ValidateInt64(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, test.expected); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/int64validator/at_least.go b/int64validator/at_least.go index 3725ee2..23433fe 100644 --- a/int64validator/at_least.go +++ b/int64validator/at_least.go @@ -4,12 +4,12 @@ import ( "context" "fmt" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" ) -var _ tfsdk.AttributeValidator = atLeastValidator{} +var _ validator.Int64 = atLeastValidator{} // atLeastValidator validates that an integer Attribute's value is at least a certain value. type atLeastValidator struct { @@ -26,22 +26,18 @@ func (validator atLeastValidator) MarkdownDescription(ctx context.Context) strin return validator.Description(ctx) } -// Validate performs the validation. -func (validator atLeastValidator) Validate(ctx context.Context, request tfsdk.ValidateAttributeRequest, response *tfsdk.ValidateAttributeResponse) { - i, ok := validateInt(ctx, request, response) - - if !ok { +// ValidateInt64 performs the validation. +func (v atLeastValidator) ValidateInt64(ctx context.Context, request validator.Int64Request, response *validator.Int64Response) { + if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() { return } - if i < validator.min { + if request.ConfigValue.ValueInt64() < v.min { response.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( - request.AttributePath, - validator.Description(ctx), - fmt.Sprintf("%d", i), + request.Path, + v.Description(ctx), + fmt.Sprintf("%d", request.ConfigValue.ValueInt64()), )) - - return } } @@ -52,7 +48,7 @@ func (validator atLeastValidator) Validate(ctx context.Context, request tfsdk.Va // - Is greater than or equal to the given minimum. // // Null (unconfigured) and unknown (known after apply) values are skipped. -func AtLeast(min int64) tfsdk.AttributeValidator { +func AtLeast(min int64) validator.Int64 { return atLeastValidator{ min: min, } diff --git a/int64validator/at_least_example_test.go b/int64validator/at_least_example_test.go index d87227a..42e4243 100644 --- a/int64validator/at_least_example_test.go +++ b/int64validator/at_least_example_test.go @@ -2,18 +2,17 @@ package int64validator_test import ( "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" ) func ExampleAtLeast() { - // Used within a GetSchema method of a DataSource, Provider, or Resource - _ = tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "example_attr": { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Int64Attribute{ Required: true, - Type: types.Int64Type, - Validators: []tfsdk.AttributeValidator{ + Validators: []validator.Int64{ // Validate integer value must be at least 42 int64validator.AtLeast(42), }, diff --git a/int64validator/at_least_one_of.go b/int64validator/at_least_one_of.go new file mode 100644 index 0000000..78f9024 --- /dev/null +++ b/int64validator/at_least_one_of.go @@ -0,0 +1,24 @@ +package int64validator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// AtLeastOneOf checks that of a set of path.Expression, +// including the attribute this validator is applied to, +// at least one has a non-null value. +// +// This implements the validation logic declaratively within the tfsdk.Schema. +// Refer to [datasourcevalidator.AtLeastOneOf], +// [providervalidator.AtLeastOneOf], or [resourcevalidator.AtLeastOneOf] +// for declaring this type of validation outside the schema definition. +// +// Any relative path.Expression will be resolved using the attribute being +// validated. +func AtLeastOneOf(expressions ...path.Expression) validator.Int64 { + return schemavalidator.AtLeastOneOfValidator{ + PathExpressions: expressions, + } +} diff --git a/int64validator/at_least_one_of_example_test.go b/int64validator/at_least_one_of_example_test.go new file mode 100644 index 0000000..71c43be --- /dev/null +++ b/int64validator/at_least_one_of_example_test.go @@ -0,0 +1,28 @@ +package int64validator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleAtLeastOneOf() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Int64Attribute{ + Optional: true, + Validators: []validator.Int64{ + // Validate at least this attribute or other_attr should be configured. + int64validator.AtLeastOneOf(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.StringAttribute{ + Optional: true, + }, + }, + } +} diff --git a/int64validator/at_least_sum_of.go b/int64validator/at_least_sum_of.go index c287f8a..fb3a8f7 100644 --- a/int64validator/at_least_sum_of.go +++ b/int64validator/at_least_sum_of.go @@ -7,13 +7,14 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/tfsdk" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" ) -var _ tfsdk.AttributeValidator = atLeastSumOfValidator{} +var _ validator.Int64 = atLeastSumOfValidator{} // atLeastSumOfValidator validates that an integer Attribute's value is at least the sum of one // or more integer Attributes retrieved via the given path expressions. @@ -36,15 +37,14 @@ func (av atLeastSumOfValidator) MarkdownDescription(ctx context.Context) string return av.Description(ctx) } -// Validate performs the validation. -func (av atLeastSumOfValidator) Validate(ctx context.Context, request tfsdk.ValidateAttributeRequest, response *tfsdk.ValidateAttributeResponse) { - i, ok := validateInt(ctx, request, response) - if !ok { +// ValidateInt64 performs the validation. +func (av atLeastSumOfValidator) ValidateInt64(ctx context.Context, request validator.Int64Request, response *validator.Int64Response) { + if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() { return } // Ensure input path expressions resolution against the current attribute - expressions := request.AttributePathExpression.MergeExpressions(av.attributesToSumPathExpressions...) + expressions := request.PathExpression.MergeExpressions(av.attributesToSumPathExpressions...) // Sum the value of all the attributes involved, but only if they are all known. var sumOfAttribs int64 @@ -60,7 +60,7 @@ func (av atLeastSumOfValidator) Validate(ctx context.Context, request tfsdk.Vali for _, mp := range matchedPaths { // If the user specifies the same attribute this validator is applied to, // also as part of the input, skip it - if mp.Equal(request.AttributePath) { + if mp.Equal(request.Path) { continue } @@ -92,14 +92,12 @@ func (av atLeastSumOfValidator) Validate(ctx context.Context, request tfsdk.Vali } } - if i < sumOfAttribs { + if request.ConfigValue.ValueInt64() < sumOfAttribs { response.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( - request.AttributePath, + request.Path, av.Description(ctx), - fmt.Sprintf("%d", i), + fmt.Sprintf("%d", request.ConfigValue.ValueInt64()), )) - - return } } @@ -110,6 +108,6 @@ func (av atLeastSumOfValidator) Validate(ctx context.Context, request tfsdk.Vali // - Is at least the sum of the attributes retrieved via the given path expression(s). // // Null (unconfigured) and unknown (known after apply) values are skipped. -func AtLeastSumOf(attributesToSumPathExpressions ...path.Expression) tfsdk.AttributeValidator { +func AtLeastSumOf(attributesToSumPathExpressions ...path.Expression) validator.Int64 { return atLeastSumOfValidator{attributesToSumPathExpressions} } diff --git a/int64validator/at_least_sum_of_example_test.go b/int64validator/at_least_sum_of_example_test.go index a202c30..2d15cac 100644 --- a/int64validator/at_least_sum_of_example_test.go +++ b/int64validator/at_least_sum_of_example_test.go @@ -2,19 +2,18 @@ package int64validator_test import ( "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" ) func ExampleAtLeastSumOf() { - // Used within a GetSchema method of a DataSource, Provider, or Resource - _ = tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "example_attr": { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Int64Attribute{ Required: true, - Type: types.Int64Type, - Validators: []tfsdk.AttributeValidator{ + Validators: []validator.Int64{ // Validate this integer value must be at least the // summed integer values of other_attr1 and other_attr2. int64validator.AtLeastSumOf(path.Expressions{ @@ -23,13 +22,11 @@ func ExampleAtLeastSumOf() { }...), }, }, - "other_attr1": { + "other_attr1": schema.Int64Attribute{ Required: true, - Type: types.Int64Type, }, - "other_attr2": { + "other_attr2": schema.Int64Attribute{ Required: true, - Type: types.Int64Type, }, }, } diff --git a/int64validator/at_least_sum_of_test.go b/int64validator/at_least_sum_of_test.go index 9a2765b..383e0a9 100644 --- a/int64validator/at_least_sum_of_test.go +++ b/int64validator/at_least_sum_of_test.go @@ -4,8 +4,9 @@ import ( "context" "testing" - "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/tfsdk" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" @@ -15,16 +16,12 @@ func TestAtLeastSumOfValidator(t *testing.T) { t.Parallel() type testCase struct { - val attr.Value + val types.Int64 attributesToSumExpressions path.Expressions requestConfigRaw map[string]tftypes.Value expectError bool } tests := map[string]testCase{ - "not an Int64": { - val: types.BoolValue(true), - expectError: true, - }, "unknown Int64": { val: types.Int64Unknown(), }, @@ -149,25 +146,25 @@ func TestAtLeastSumOfValidator(t *testing.T) { for name, test := range tests { name, test := name, test t.Run(name, func(t *testing.T) { - request := tfsdk.ValidateAttributeRequest{ - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - AttributeConfig: test.val, + request := validator.Int64Request{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, Config: tfsdk.Config{ Raw: tftypes.NewValue(tftypes.Object{}, test.requestConfigRaw), - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": {Type: types.Int64Type}, - "one": {Type: types.Int64Type}, - "two": {Type: types.Int64Type}, + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.Int64Attribute{}, + "one": schema.Int64Attribute{}, + "two": schema.Int64Attribute{}, }, }, }, } - response := tfsdk.ValidateAttributeResponse{} + response := validator.Int64Response{} - AtLeastSumOf(test.attributesToSumExpressions...).Validate(context.Background(), request, &response) + AtLeastSumOf(test.attributesToSumExpressions...).ValidateInt64(context.Background(), request, &response) if !response.Diagnostics.HasError() && test.expectError { t.Fatal("expected error, got no error") diff --git a/int64validator/at_least_test.go b/int64validator/at_least_test.go index 3f8e2a2..af17ec3 100644 --- a/int64validator/at_least_test.go +++ b/int64validator/at_least_test.go @@ -4,9 +4,8 @@ import ( "context" "testing" - "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" @@ -16,15 +15,11 @@ func TestAtLeastValidator(t *testing.T) { t.Parallel() type testCase struct { - val attr.Value + val types.Int64 min int64 expectError bool } tests := map[string]testCase{ - "not an Int64": { - val: types.BoolValue(true), - expectError: true, - }, "unknown Int64": { val: types.Int64Unknown(), min: 1, @@ -51,13 +46,13 @@ func TestAtLeastValidator(t *testing.T) { for name, test := range tests { name, test := name, test t.Run(name, func(t *testing.T) { - request := tfsdk.ValidateAttributeRequest{ - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - AttributeConfig: test.val, + request := validator.Int64Request{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, } - response := tfsdk.ValidateAttributeResponse{} - int64validator.AtLeast(test.min).Validate(context.TODO(), request, &response) + response := validator.Int64Response{} + int64validator.AtLeast(test.min).ValidateInt64(context.TODO(), request, &response) if !response.Diagnostics.HasError() && test.expectError { t.Fatal("expected error, got no error") diff --git a/int64validator/at_most.go b/int64validator/at_most.go index 885c973..4d510bb 100644 --- a/int64validator/at_most.go +++ b/int64validator/at_most.go @@ -4,12 +4,12 @@ import ( "context" "fmt" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" ) -var _ tfsdk.AttributeValidator = atMostValidator{} +var _ validator.Int64 = atMostValidator{} // atMostValidator validates that an integer Attribute's value is at most a certain value. type atMostValidator struct { @@ -26,22 +26,18 @@ func (validator atMostValidator) MarkdownDescription(ctx context.Context) string return validator.Description(ctx) } -// Validate performs the validation. -func (validator atMostValidator) Validate(ctx context.Context, request tfsdk.ValidateAttributeRequest, response *tfsdk.ValidateAttributeResponse) { - i, ok := validateInt(ctx, request, response) - - if !ok { +// ValidateInt64 performs the validation. +func (v atMostValidator) ValidateInt64(ctx context.Context, request validator.Int64Request, response *validator.Int64Response) { + if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() { return } - if i > validator.max { + if request.ConfigValue.ValueInt64() > v.max { response.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( - request.AttributePath, - validator.Description(ctx), - fmt.Sprintf("%d", i), + request.Path, + v.Description(ctx), + fmt.Sprintf("%d", request.ConfigValue.ValueInt64()), )) - - return } } @@ -52,7 +48,7 @@ func (validator atMostValidator) Validate(ctx context.Context, request tfsdk.Val // - Is less than or equal to the given maximum. // // Null (unconfigured) and unknown (known after apply) values are skipped. -func AtMost(max int64) tfsdk.AttributeValidator { +func AtMost(max int64) validator.Int64 { return atMostValidator{ max: max, } diff --git a/int64validator/at_most_example_test.go b/int64validator/at_most_example_test.go index d348376..66ea177 100644 --- a/int64validator/at_most_example_test.go +++ b/int64validator/at_most_example_test.go @@ -2,18 +2,17 @@ package int64validator_test import ( "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" ) func ExampleAtMost() { - // Used within a GetSchema method of a DataSource, Provider, or Resource - _ = tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "example_attr": { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Int64Attribute{ Required: true, - Type: types.Int64Type, - Validators: []tfsdk.AttributeValidator{ + Validators: []validator.Int64{ // Validate integer value must be at most 42 int64validator.AtMost(42), }, diff --git a/int64validator/at_most_sum_of.go b/int64validator/at_most_sum_of.go index 121ab75..eaa8496 100644 --- a/int64validator/at_most_sum_of.go +++ b/int64validator/at_most_sum_of.go @@ -7,13 +7,14 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/tfsdk" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" ) -var _ tfsdk.AttributeValidator = atMostSumOfValidator{} +var _ validator.Int64 = atMostSumOfValidator{} // atMostSumOfValidator validates that an integer Attribute's value is at most the sum of one // or more integer Attributes retrieved via the given path expressions. @@ -36,15 +37,14 @@ func (av atMostSumOfValidator) MarkdownDescription(ctx context.Context) string { return av.Description(ctx) } -// Validate performs the validation. -func (av atMostSumOfValidator) Validate(ctx context.Context, request tfsdk.ValidateAttributeRequest, response *tfsdk.ValidateAttributeResponse) { - i, ok := validateInt(ctx, request, response) - if !ok { +// ValidateInt64 performs the validation. +func (av atMostSumOfValidator) ValidateInt64(ctx context.Context, request validator.Int64Request, response *validator.Int64Response) { + if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() { return } // Ensure input path expressions resolution against the current attribute - expressions := request.AttributePathExpression.MergeExpressions(av.attributesToSumPathExpressions...) + expressions := request.PathExpression.MergeExpressions(av.attributesToSumPathExpressions...) // Sum the value of all the attributes involved, but only if they are all known. var sumOfAttribs int64 @@ -60,7 +60,7 @@ func (av atMostSumOfValidator) Validate(ctx context.Context, request tfsdk.Valid for _, mp := range matchedPaths { // If the user specifies the same attribute this validator is applied to, // also as part of the input, skip it - if mp.Equal(request.AttributePath) { + if mp.Equal(request.Path) { continue } @@ -92,14 +92,12 @@ func (av atMostSumOfValidator) Validate(ctx context.Context, request tfsdk.Valid } } - if i > sumOfAttribs { + if request.ConfigValue.ValueInt64() > sumOfAttribs { response.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( - request.AttributePath, + request.Path, av.Description(ctx), - fmt.Sprintf("%d", i), + fmt.Sprintf("%d", request.ConfigValue.ValueInt64()), )) - - return } } @@ -110,6 +108,6 @@ func (av atMostSumOfValidator) Validate(ctx context.Context, request tfsdk.Valid // - Is at most the sum of the given attributes retrieved via the given path expression(s). // // Null (unconfigured) and unknown (known after apply) values are skipped. -func AtMostSumOf(attributesToSumPathExpressions ...path.Expression) tfsdk.AttributeValidator { +func AtMostSumOf(attributesToSumPathExpressions ...path.Expression) validator.Int64 { return atMostSumOfValidator{attributesToSumPathExpressions} } diff --git a/int64validator/at_most_sum_of_example_test.go b/int64validator/at_most_sum_of_example_test.go index 3339d5c..fd1be9d 100644 --- a/int64validator/at_most_sum_of_example_test.go +++ b/int64validator/at_most_sum_of_example_test.go @@ -2,19 +2,18 @@ package int64validator_test import ( "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" ) func ExampleAtMostSumOf() { - // Used within a GetSchema method of a DataSource, Provider, or Resource - _ = tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "example_attr": { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Int64Attribute{ Required: true, - Type: types.Int64Type, - Validators: []tfsdk.AttributeValidator{ + Validators: []validator.Int64{ // Validate this integer value must be at most the // summed integer values of other_attr1 and other_attr2. int64validator.AtMostSumOf(path.Expressions{ @@ -23,13 +22,11 @@ func ExampleAtMostSumOf() { }...), }, }, - "other_attr1": { + "other_attr1": schema.Int64Attribute{ Required: true, - Type: types.Int64Type, }, - "other_attr2": { + "other_attr2": schema.Int64Attribute{ Required: true, - Type: types.Int64Type, }, }, } diff --git a/int64validator/at_most_sum_of_test.go b/int64validator/at_most_sum_of_test.go index ac74a66..d4da32b 100644 --- a/int64validator/at_most_sum_of_test.go +++ b/int64validator/at_most_sum_of_test.go @@ -4,8 +4,9 @@ import ( "context" "testing" - "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/tfsdk" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" @@ -15,16 +16,12 @@ func TestAtMostSumOfValidator(t *testing.T) { t.Parallel() type testCase struct { - val attr.Value + val types.Int64 attributesToSumPathExpressions path.Expressions requestConfigRaw map[string]tftypes.Value expectError bool } tests := map[string]testCase{ - "not an Int64": { - val: types.BoolValue(true), - expectError: true, - }, "unknown Int64": { val: types.Int64Unknown(), }, @@ -149,25 +146,25 @@ func TestAtMostSumOfValidator(t *testing.T) { for name, test := range tests { name, test := name, test t.Run(name, func(t *testing.T) { - request := tfsdk.ValidateAttributeRequest{ - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - AttributeConfig: test.val, + request := validator.Int64Request{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, Config: tfsdk.Config{ Raw: tftypes.NewValue(tftypes.Object{}, test.requestConfigRaw), - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": {Type: types.Int64Type}, - "one": {Type: types.Int64Type}, - "two": {Type: types.Int64Type}, + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.Int64Attribute{}, + "one": schema.Int64Attribute{}, + "two": schema.Int64Attribute{}, }, }, }, } - response := tfsdk.ValidateAttributeResponse{} + response := validator.Int64Response{} - AtMostSumOf(test.attributesToSumPathExpressions...).Validate(context.Background(), request, &response) + AtMostSumOf(test.attributesToSumPathExpressions...).ValidateInt64(context.Background(), request, &response) if !response.Diagnostics.HasError() && test.expectError { t.Fatal("expected error, got no error") diff --git a/int64validator/at_most_test.go b/int64validator/at_most_test.go index debfac6..9287560 100644 --- a/int64validator/at_most_test.go +++ b/int64validator/at_most_test.go @@ -4,9 +4,8 @@ import ( "context" "testing" - "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" @@ -16,15 +15,11 @@ func TestAtMostValidator(t *testing.T) { t.Parallel() type testCase struct { - val attr.Value + val types.Int64 max int64 expectError bool } tests := map[string]testCase{ - "not an Int64": { - val: types.BoolValue(true), - expectError: true, - }, "unknown Int64": { val: types.Int64Unknown(), max: 2, @@ -51,13 +46,13 @@ func TestAtMostValidator(t *testing.T) { for name, test := range tests { name, test := name, test t.Run(name, func(t *testing.T) { - request := tfsdk.ValidateAttributeRequest{ - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - AttributeConfig: test.val, + request := validator.Int64Request{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, } - response := tfsdk.ValidateAttributeResponse{} - int64validator.AtMost(test.max).Validate(context.TODO(), request, &response) + response := validator.Int64Response{} + int64validator.AtMost(test.max).ValidateInt64(context.TODO(), request, &response) if !response.Diagnostics.HasError() && test.expectError { t.Fatal("expected error, got no error") diff --git a/int64validator/between.go b/int64validator/between.go index e6e0183..1956ee9 100644 --- a/int64validator/between.go +++ b/int64validator/between.go @@ -4,12 +4,12 @@ import ( "context" "fmt" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" ) -var _ tfsdk.AttributeValidator = betweenValidator{} +var _ validator.Int64 = betweenValidator{} // betweenValidator validates that an integer Attribute's value is in a range. type betweenValidator struct { @@ -26,22 +26,18 @@ func (validator betweenValidator) MarkdownDescription(ctx context.Context) strin return validator.Description(ctx) } -// Validate performs the validation. -func (validator betweenValidator) Validate(ctx context.Context, request tfsdk.ValidateAttributeRequest, response *tfsdk.ValidateAttributeResponse) { - i, ok := validateInt(ctx, request, response) - - if !ok { +// ValidateInt64 performs the validation. +func (v betweenValidator) ValidateInt64(ctx context.Context, request validator.Int64Request, response *validator.Int64Response) { + if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() { return } - if i < validator.min || i > validator.max { + if request.ConfigValue.ValueInt64() < v.min || request.ConfigValue.ValueInt64() > v.max { response.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( - request.AttributePath, - validator.Description(ctx), - fmt.Sprintf("%d", i), + request.Path, + v.Description(ctx), + fmt.Sprintf("%d", request.ConfigValue.ValueInt64()), )) - - return } } @@ -52,7 +48,7 @@ func (validator betweenValidator) Validate(ctx context.Context, request tfsdk.Va // - Is greater than or equal to the given minimum and less than or equal to the given maximum. // // Null (unconfigured) and unknown (known after apply) values are skipped. -func Between(min, max int64) tfsdk.AttributeValidator { +func Between(min, max int64) validator.Int64 { if min > max { return nil } diff --git a/int64validator/between_example_test.go b/int64validator/between_example_test.go index b75fe15..1b6e453 100644 --- a/int64validator/between_example_test.go +++ b/int64validator/between_example_test.go @@ -2,18 +2,17 @@ package int64validator_test import ( "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" ) func ExampleBetween() { - // Used within a GetSchema method of a DataSource, Provider, or Resource - _ = tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "example_attr": { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Int64Attribute{ Required: true, - Type: types.Int64Type, - Validators: []tfsdk.AttributeValidator{ + Validators: []validator.Int64{ // Validate integer value must be at least 10 and at most 100 int64validator.Between(10, 100), }, diff --git a/int64validator/between_test.go b/int64validator/between_test.go index 9a9996f..94d7a0c 100644 --- a/int64validator/between_test.go +++ b/int64validator/between_test.go @@ -4,9 +4,8 @@ import ( "context" "testing" - "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" @@ -16,16 +15,12 @@ func TestBetweenValidator(t *testing.T) { t.Parallel() type testCase struct { - val attr.Value + val types.Int64 min int64 max int64 expectError bool } tests := map[string]testCase{ - "not an Int64": { - val: types.BoolValue(true), - expectError: true, - }, "unknown Int64": { val: types.Int64Unknown(), min: 1, @@ -68,13 +63,13 @@ func TestBetweenValidator(t *testing.T) { for name, test := range tests { name, test := name, test t.Run(name, func(t *testing.T) { - request := tfsdk.ValidateAttributeRequest{ - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - AttributeConfig: test.val, + request := validator.Int64Request{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, } - response := tfsdk.ValidateAttributeResponse{} - int64validator.Between(test.min, test.max).Validate(context.TODO(), request, &response) + response := validator.Int64Response{} + int64validator.Between(test.min, test.max).ValidateInt64(context.TODO(), request, &response) if !response.Diagnostics.HasError() && test.expectError { t.Fatal("expected error, got no error") diff --git a/int64validator/conflicts_with.go b/int64validator/conflicts_with.go new file mode 100644 index 0000000..4237803 --- /dev/null +++ b/int64validator/conflicts_with.go @@ -0,0 +1,24 @@ +package int64validator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// ConflictsWith checks that a set of path.Expression, +// including the attribute the validator is applied to, +// do not have a value simultaneously. +// +// This implements the validation logic declaratively within the schema. +// Refer to [datasourcevalidator.Conflicting], +// [providervalidator.Conflicting], or [resourcevalidator.Conflicting] +// for declaring this type of validation outside the schema definition. +// +// Relative path.Expression will be resolved using the attribute being +// validated. +func ConflictsWith(expressions ...path.Expression) validator.Int64 { + return schemavalidator.ConflictsWithValidator{ + PathExpressions: expressions, + } +} diff --git a/int64validator/conflicts_with_example_test.go b/int64validator/conflicts_with_example_test.go new file mode 100644 index 0000000..9776721 --- /dev/null +++ b/int64validator/conflicts_with_example_test.go @@ -0,0 +1,28 @@ +package int64validator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleConflictsWith() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Int64Attribute{ + Optional: true, + Validators: []validator.Int64{ + // Validate this attribute must not be configured with other_attr. + int64validator.ConflictsWith(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.StringAttribute{ + Optional: true, + }, + }, + } +} diff --git a/int64validator/equal_to_sum_of.go b/int64validator/equal_to_sum_of.go index 8228a79..fa9a270 100644 --- a/int64validator/equal_to_sum_of.go +++ b/int64validator/equal_to_sum_of.go @@ -7,13 +7,14 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/tfsdk" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" ) -var _ tfsdk.AttributeValidator = equalToSumOfValidator{} +var _ validator.Int64 = equalToSumOfValidator{} // equalToSumOfValidator validates that an integer Attribute's value equals the sum of one // or more integer Attributes retrieved via the given path expressions. @@ -36,15 +37,14 @@ func (av equalToSumOfValidator) MarkdownDescription(ctx context.Context) string return av.Description(ctx) } -// Validate performs the validation. -func (av equalToSumOfValidator) Validate(ctx context.Context, request tfsdk.ValidateAttributeRequest, response *tfsdk.ValidateAttributeResponse) { - i, ok := validateInt(ctx, request, response) - if !ok { +// ValidateInt64 performs the validation. +func (av equalToSumOfValidator) ValidateInt64(ctx context.Context, request validator.Int64Request, response *validator.Int64Response) { + if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() { return } // Ensure input path expressions resolution against the current attribute - expressions := request.AttributePathExpression.MergeExpressions(av.attributesToSumPathExpressions...) + expressions := request.PathExpression.MergeExpressions(av.attributesToSumPathExpressions...) // Sum the value of all the attributes involved, but only if they are all known. var sumOfAttribs int64 @@ -60,7 +60,7 @@ func (av equalToSumOfValidator) Validate(ctx context.Context, request tfsdk.Vali for _, mp := range matchedPaths { // If the user specifies the same attribute this validator is applied to, // also as part of the input, skip it - if mp.Equal(request.AttributePath) { + if mp.Equal(request.Path) { continue } @@ -92,14 +92,12 @@ func (av equalToSumOfValidator) Validate(ctx context.Context, request tfsdk.Vali } } - if i != sumOfAttribs { + if request.ConfigValue.ValueInt64() != sumOfAttribs { response.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( - request.AttributePath, + request.Path, av.Description(ctx), - fmt.Sprintf("%d", i), + fmt.Sprintf("%d", request.ConfigValue.ValueInt64()), )) - - return } } @@ -110,6 +108,6 @@ func (av equalToSumOfValidator) Validate(ctx context.Context, request tfsdk.Vali // - Is equal to the sum of the given attributes retrieved via the given path expression(s). // // Null (unconfigured) and unknown (known after apply) values are skipped. -func EqualToSumOf(attributesToSumPathExpressions ...path.Expression) tfsdk.AttributeValidator { +func EqualToSumOf(attributesToSumPathExpressions ...path.Expression) validator.Int64 { return equalToSumOfValidator{attributesToSumPathExpressions} } diff --git a/int64validator/equal_to_sum_of_example_test.go b/int64validator/equal_to_sum_of_example_test.go index db0cde1..c06f386 100644 --- a/int64validator/equal_to_sum_of_example_test.go +++ b/int64validator/equal_to_sum_of_example_test.go @@ -2,19 +2,18 @@ package int64validator_test import ( "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" ) func ExampleEqualToSumOf() { - // Used within a GetSchema method of a DataSource, Provider, or Resource - _ = tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "example_attr": { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Int64Attribute{ Required: true, - Type: types.Int64Type, - Validators: []tfsdk.AttributeValidator{ + Validators: []validator.Int64{ // Validate this integer value must be equal to the // summed integer values of other_attr1 and other_attr2. int64validator.EqualToSumOf(path.Expressions{ @@ -23,13 +22,11 @@ func ExampleEqualToSumOf() { }...), }, }, - "other_attr1": { + "other_attr1": schema.Int64Attribute{ Required: true, - Type: types.Int64Type, }, - "other_attr2": { + "other_attr2": schema.Int64Attribute{ Required: true, - Type: types.Int64Type, }, }, } diff --git a/int64validator/equal_to_sum_of_test.go b/int64validator/equal_to_sum_of_test.go index dc981d0..13c647b 100644 --- a/int64validator/equal_to_sum_of_test.go +++ b/int64validator/equal_to_sum_of_test.go @@ -4,8 +4,9 @@ import ( "context" "testing" - "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/tfsdk" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" @@ -15,16 +16,12 @@ func TestEqualToSumOfValidator(t *testing.T) { t.Parallel() type testCase struct { - val attr.Value + val types.Int64 attributesToSumPathExpressions path.Expressions requestConfigRaw map[string]tftypes.Value expectError bool } tests := map[string]testCase{ - "not an Int64": { - val: types.BoolValue(true), - expectError: true, - }, "unknown Int64": { val: types.Int64Unknown(), }, @@ -150,25 +147,25 @@ func TestEqualToSumOfValidator(t *testing.T) { for name, test := range tests { name, test := name, test t.Run(name, func(t *testing.T) { - request := tfsdk.ValidateAttributeRequest{ - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - AttributeConfig: test.val, + request := validator.Int64Request{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, Config: tfsdk.Config{ Raw: tftypes.NewValue(tftypes.Object{}, test.requestConfigRaw), - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": {Type: types.Int64Type}, - "one": {Type: types.Int64Type}, - "two": {Type: types.Int64Type}, + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.Int64Attribute{}, + "one": schema.Int64Attribute{}, + "two": schema.Int64Attribute{}, }, }, }, } - response := tfsdk.ValidateAttributeResponse{} + response := validator.Int64Response{} - EqualToSumOf(test.attributesToSumPathExpressions...).Validate(context.Background(), request, &response) + EqualToSumOf(test.attributesToSumPathExpressions...).ValidateInt64(context.Background(), request, &response) if !response.Diagnostics.HasError() && test.expectError { t.Fatal("expected error, got no error") diff --git a/int64validator/exactly_one_of.go b/int64validator/exactly_one_of.go new file mode 100644 index 0000000..4fc2e80 --- /dev/null +++ b/int64validator/exactly_one_of.go @@ -0,0 +1,25 @@ +package int64validator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// ExactlyOneOf checks that of a set of path.Expression, +// including the attribute the validator is applied to, +// one and only one attribute has a value. +// It will also cause a validation error if none are specified. +// +// This implements the validation logic declaratively within the schema. +// Refer to [datasourcevalidator.ExactlyOneOf], +// [providervalidator.ExactlyOneOf], or [resourcevalidator.ExactlyOneOf] +// for declaring this type of validation outside the schema definition. +// +// Relative path.Expression will be resolved using the attribute being +// validated. +func ExactlyOneOf(expressions ...path.Expression) validator.Int64 { + return schemavalidator.ExactlyOneOfValidator{ + PathExpressions: expressions, + } +} diff --git a/int64validator/exactly_one_of_example_test.go b/int64validator/exactly_one_of_example_test.go new file mode 100644 index 0000000..a2f746a --- /dev/null +++ b/int64validator/exactly_one_of_example_test.go @@ -0,0 +1,28 @@ +package int64validator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleExactlyOneOf() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Int64Attribute{ + Optional: true, + Validators: []validator.Int64{ + // Validate only this attribute or other_attr is configured. + int64validator.ExactlyOneOf(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.StringAttribute{ + Optional: true, + }, + }, + } +} diff --git a/int64validator/none_of.go b/int64validator/none_of.go index b2e90fc..88d74f7 100644 --- a/int64validator/none_of.go +++ b/int64validator/none_of.go @@ -1,20 +1,62 @@ package int64validator import ( - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" - "github.com/hashicorp/terraform-plugin-framework-validators/internal/primitivevalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" ) -// NoneOf checks that the int64 held in the attribute -// is none of the given `unacceptableInts`. -func NoneOf(unacceptableInts ...int64) tfsdk.AttributeValidator { - unacceptableIntValues := make([]attr.Value, 0, len(unacceptableInts)) - for _, i := range unacceptableInts { - unacceptableIntValues = append(unacceptableIntValues, types.Int64Value(i)) +var _ validator.Int64 = noneOfValidator{} + +// noneOfValidator validates that the value does not match one of the values. +type noneOfValidator struct { + values []types.Int64 +} + +func (v noneOfValidator) Description(ctx context.Context) string { + return v.MarkdownDescription(ctx) +} + +func (v noneOfValidator) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("value must be none of: %q", v.values) +} + +func (v noneOfValidator) ValidateInt64(ctx context.Context, request validator.Int64Request, response *validator.Int64Response) { + if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() { + return + } + + value := request.ConfigValue + + for _, otherValue := range v.values { + if !value.Equal(otherValue) { + continue + } + + response.Diagnostics.Append(validatordiag.InvalidAttributeValueMatchDiagnostic( + request.Path, + v.Description(ctx), + value.String(), + )) + + break } +} - return primitivevalidator.NoneOf(unacceptableIntValues...) +// NoneOf checks that the Int64 held in the attribute +// is none of the given `values`. +func NoneOf(values ...int64) validator.Int64 { + frameworkValues := make([]types.Int64, 0, len(values)) + + for _, value := range values { + frameworkValues = append(frameworkValues, types.Int64Value(value)) + } + + return noneOfValidator{ + values: frameworkValues, + } } diff --git a/int64validator/none_of_example_test.go b/int64validator/none_of_example_test.go index e57eccb..2e895b9 100644 --- a/int64validator/none_of_example_test.go +++ b/int64validator/none_of_example_test.go @@ -2,18 +2,17 @@ package int64validator_test import ( "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" ) func ExampleNoneOf() { - // Used within a GetSchema method of a DataSource, Provider, or Resource - _ = tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "example_attr": { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Int64Attribute{ Required: true, - Type: types.Int64Type, - Validators: []tfsdk.AttributeValidator{ + Validators: []validator.Int64{ // Validate integer value must not be 12, 24, or 48 int64validator.NoneOf([]int64{12, 24, 48}...), }, diff --git a/int64validator/none_of_test.go b/int64validator/none_of_test.go index c91fee0..9f3d6c2 100644 --- a/int64validator/none_of_test.go +++ b/int64validator/none_of_test.go @@ -4,8 +4,7 @@ import ( "context" "testing" - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" @@ -15,8 +14,8 @@ func TestNoneOfValidator(t *testing.T) { t.Parallel() type testCase struct { - in attr.Value - validator tfsdk.AttributeValidator + in types.Int64 + validator validator.Int64 expErrors int } @@ -63,11 +62,11 @@ func TestNoneOfValidator(t *testing.T) { for name, test := range testCases { name, test := name, test t.Run(name, func(t *testing.T) { - req := tfsdk.ValidateAttributeRequest{ - AttributeConfig: test.in, + req := validator.Int64Request{ + ConfigValue: test.in, } - res := tfsdk.ValidateAttributeResponse{} - test.validator.Validate(context.TODO(), req, &res) + res := validator.Int64Response{} + test.validator.ValidateInt64(context.TODO(), req, &res) if test.expErrors > 0 && !res.Diagnostics.HasError() { t.Fatalf("expected %d error(s), got none", test.expErrors) diff --git a/int64validator/one_of.go b/int64validator/one_of.go index 93d90cd..b2bc2c1 100644 --- a/int64validator/one_of.go +++ b/int64validator/one_of.go @@ -1,20 +1,60 @@ package int64validator import ( - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" - "github.com/hashicorp/terraform-plugin-framework-validators/internal/primitivevalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" ) -// OneOf checks that the int64 held in the attribute -// is one of the given `acceptableInts`. -func OneOf(acceptableInts ...int64) tfsdk.AttributeValidator { - acceptableIntValues := make([]attr.Value, 0, len(acceptableInts)) - for _, i := range acceptableInts { - acceptableIntValues = append(acceptableIntValues, types.Int64Value(i)) +var _ validator.Int64 = oneOfValidator{} + +// oneOfValidator validates that the value matches one of expected values. +type oneOfValidator struct { + values []types.Int64 +} + +func (v oneOfValidator) Description(ctx context.Context) string { + return v.MarkdownDescription(ctx) +} + +func (v oneOfValidator) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("value must be one of: %q", v.values) +} + +func (v oneOfValidator) ValidateInt64(ctx context.Context, request validator.Int64Request, response *validator.Int64Response) { + if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() { + return } - return primitivevalidator.OneOf(acceptableIntValues...) + value := request.ConfigValue + + for _, otherValue := range v.values { + if value.Equal(otherValue) { + return + } + } + + response.Diagnostics.Append(validatordiag.InvalidAttributeValueMatchDiagnostic( + request.Path, + v.Description(ctx), + value.String(), + )) +} + +// OneOf checks that the Int64 held in the attribute +// is none of the given `values`. +func OneOf(values ...int64) validator.Int64 { + frameworkValues := make([]types.Int64, 0, len(values)) + + for _, value := range values { + frameworkValues = append(frameworkValues, types.Int64Value(value)) + } + + return oneOfValidator{ + values: frameworkValues, + } } diff --git a/int64validator/one_of_example_test.go b/int64validator/one_of_example_test.go index 5d4cc8a..50d4d76 100644 --- a/int64validator/one_of_example_test.go +++ b/int64validator/one_of_example_test.go @@ -2,18 +2,17 @@ package int64validator_test import ( "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" ) func ExampleOneOf() { - // Used within a GetSchema method of a DataSource, Provider, or Resource - _ = tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "example_attr": { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Int64Attribute{ Required: true, - Type: types.Int64Type, - Validators: []tfsdk.AttributeValidator{ + Validators: []validator.Int64{ // Validate integer value must be 12, 24, or 48 int64validator.OneOf([]int64{12, 24, 48}...), }, diff --git a/int64validator/one_of_test.go b/int64validator/one_of_test.go index 693d52a..ff541e2 100644 --- a/int64validator/one_of_test.go +++ b/int64validator/one_of_test.go @@ -4,8 +4,7 @@ import ( "context" "testing" - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" @@ -15,8 +14,8 @@ func TestOneOfValidator(t *testing.T) { t.Parallel() type testCase struct { - in attr.Value - validator tfsdk.AttributeValidator + in types.Int64 + validator validator.Int64 expErrors int } @@ -63,11 +62,11 @@ func TestOneOfValidator(t *testing.T) { for name, test := range testCases { name, test := name, test t.Run(name, func(t *testing.T) { - req := tfsdk.ValidateAttributeRequest{ - AttributeConfig: test.in, + req := validator.Int64Request{ + ConfigValue: test.in, } - res := tfsdk.ValidateAttributeResponse{} - test.validator.Validate(context.TODO(), req, &res) + res := validator.Int64Response{} + test.validator.ValidateInt64(context.TODO(), req, &res) if test.expErrors > 0 && !res.Diagnostics.HasError() { t.Fatalf("expected %d error(s), got none", test.expErrors) diff --git a/int64validator/type_validation.go b/int64validator/type_validation.go deleted file mode 100644 index c024a5a..0000000 --- a/int64validator/type_validation.go +++ /dev/null @@ -1,24 +0,0 @@ -package int64validator - -import ( - "context" - - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" -) - -// validateInt ensures that the request contains an Int64 value. -func validateInt(ctx context.Context, request tfsdk.ValidateAttributeRequest, response *tfsdk.ValidateAttributeResponse) (int64, bool) { - var i types.Int64 - diags := tfsdk.ValueAs(ctx, request.AttributeConfig, &i) - response.Diagnostics.Append(diags...) - if diags.HasError() { - return 0, false - } - - if i.IsUnknown() || i.IsNull() { - return 0, false - } - - return i.ValueInt64(), true -} diff --git a/int64validator/type_validation_test.go b/int64validator/type_validation_test.go deleted file mode 100644 index 62a89fc..0000000 --- a/int64validator/type_validation_test.go +++ /dev/null @@ -1,99 +0,0 @@ -package int64validator - -import ( - "context" - "testing" - - "github.com/google/go-cmp/cmp" - "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" -) - -func TestValidateInt(t *testing.T) { - t.Parallel() - - testCases := map[string]struct { - request tfsdk.ValidateAttributeRequest - expectedInt64 int64 - expectedOk bool - expectedDiagSummary string - expectedDiagDetail string - }{ - "invalid-type": { - request: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.BoolValue(true), - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - }, - expectedInt64: 0.0, - expectedOk: false, - expectedDiagSummary: "Value Conversion Error", - expectedDiagDetail: "An unexpected error was encountered trying to convert into a Terraform value. This is always an error in the provider. Please report the following to the provider developer:\n\nCannot use attr.Value types.Int64, only types.Bool is supported because types.primitive is the type in the schema", - }, - "int64-null": { - request: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.Int64Null(), - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - }, - expectedInt64: 0.0, - expectedOk: false, - expectedDiagSummary: "", - expectedDiagDetail: "", - }, - "int64-value": { - request: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.Int64Value(123), - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - }, - expectedInt64: 123, - expectedOk: true, - }, - "int64-unknown": { - request: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.Int64Unknown(), - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - }, - expectedInt64: 0.0, - expectedOk: false, - expectedDiagSummary: "", - expectedDiagDetail: "", - }, - } - - for name, testCase := range testCases { - name, testCase := name, testCase - - t.Run(name, func(t *testing.T) { - t.Parallel() - - res := tfsdk.ValidateAttributeResponse{} - gotInt64, gotOk := validateInt(context.Background(), testCase.request, &res) - - if res.Diagnostics.HasError() { - if res.Diagnostics.ErrorsCount() != 1 { - t.Errorf("expected an error but found none") - } else { - if diff := cmp.Diff(res.Diagnostics[0].Summary(), testCase.expectedDiagSummary); diff != "" { - t.Errorf("unexpected diagnostic summary difference: %s", diff) - } - - if diff := cmp.Diff(res.Diagnostics[0].Detail(), testCase.expectedDiagDetail); diff != "" { - t.Errorf("unexpected diagnostic summary difference: %s", diff) - } - } - } - - if diff := cmp.Diff(gotInt64, testCase.expectedInt64); diff != "" { - t.Errorf("unexpected int64 difference: %s", diff) - } - - if diff := cmp.Diff(gotOk, testCase.expectedOk); diff != "" { - t.Errorf("unexpected ok difference: %s", diff) - } - }) - } -} diff --git a/internal/configvalidator/at_least_one_of_test.go b/internal/configvalidator/at_least_one_of_test.go index 606bc8d..229460a 100644 --- a/internal/configvalidator/at_least_one_of_test.go +++ b/internal/configvalidator/at_least_one_of_test.go @@ -11,8 +11,8 @@ import ( "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" ) @@ -29,11 +29,10 @@ func TestAtLeastOneOfValidatorValidate(t *testing.T) { PathExpressions: nil, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -60,11 +59,10 @@ func TestAtLeastOneOfValidatorValidate(t *testing.T) { PathExpressions: path.Expressions{}, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -93,11 +91,10 @@ func TestAtLeastOneOfValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -130,11 +127,10 @@ func TestAtLeastOneOfValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -173,15 +169,13 @@ func TestAtLeastOneOfValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -212,15 +206,13 @@ func TestAtLeastOneOfValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -246,15 +238,13 @@ func TestAtLeastOneOfValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -281,19 +271,16 @@ func TestAtLeastOneOfValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -322,19 +309,16 @@ func TestAtLeastOneOfValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -363,19 +347,16 @@ func TestAtLeastOneOfValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -409,19 +390,16 @@ func TestAtLeastOneOfValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -450,19 +428,16 @@ func TestAtLeastOneOfValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -492,23 +467,19 @@ func TestAtLeastOneOfValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test3": { + "test3": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -540,23 +511,19 @@ func TestAtLeastOneOfValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test3": { + "test3": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -588,23 +555,19 @@ func TestAtLeastOneOfValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test3": { + "test3": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -660,15 +623,13 @@ func TestAtLeastOneOfValidatorValidateDataSource(t *testing.T) { }, req: datasource.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -697,19 +658,16 @@ func TestAtLeastOneOfValidatorValidateDataSource(t *testing.T) { }, req: datasource.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -773,15 +731,13 @@ func TestAtLeastOneOfValidatorValidateProvider(t *testing.T) { }, req: provider.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -810,19 +766,16 @@ func TestAtLeastOneOfValidatorValidateProvider(t *testing.T) { }, req: provider.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -886,15 +839,13 @@ func TestAtLeastOneOfValidatorValidateResource(t *testing.T) { }, req: resource.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -923,19 +874,16 @@ func TestAtLeastOneOfValidatorValidateResource(t *testing.T) { }, req: resource.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, diff --git a/internal/configvalidator/conflicting_test.go b/internal/configvalidator/conflicting_test.go index 62bb2e3..0f7dd82 100644 --- a/internal/configvalidator/conflicting_test.go +++ b/internal/configvalidator/conflicting_test.go @@ -11,8 +11,8 @@ import ( "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" ) @@ -29,11 +29,10 @@ func TestConflictingValidatorValidate(t *testing.T) { PathExpressions: nil, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -55,11 +54,10 @@ func TestConflictingValidatorValidate(t *testing.T) { PathExpressions: path.Expressions{}, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -83,11 +81,10 @@ func TestConflictingValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -120,11 +117,10 @@ func TestConflictingValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -163,15 +159,13 @@ func TestConflictingValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -197,15 +191,13 @@ func TestConflictingValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -231,15 +223,13 @@ func TestConflictingValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -266,19 +256,16 @@ func TestConflictingValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -307,19 +294,16 @@ func TestConflictingValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -348,19 +332,16 @@ func TestConflictingValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -389,19 +370,16 @@ func TestConflictingValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -430,19 +408,16 @@ func TestConflictingValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -478,23 +453,19 @@ func TestConflictingValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test3": { + "test3": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -532,23 +503,19 @@ func TestConflictingValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test3": { + "test3": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -586,23 +553,19 @@ func TestConflictingValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test3": { + "test3": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -664,15 +627,13 @@ func TestConflictingValidatorValidateDataSource(t *testing.T) { }, req: datasource.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -701,19 +662,16 @@ func TestConflictingValidatorValidateDataSource(t *testing.T) { }, req: datasource.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -778,15 +736,13 @@ func TestConflictingValidatorValidateProvider(t *testing.T) { }, req: provider.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -815,19 +771,16 @@ func TestConflictingValidatorValidateProvider(t *testing.T) { }, req: provider.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -892,15 +845,13 @@ func TestConflictingValidatorValidateResource(t *testing.T) { }, req: resource.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -929,19 +880,16 @@ func TestConflictingValidatorValidateResource(t *testing.T) { }, req: resource.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, diff --git a/internal/configvalidator/exactly_one_of_test.go b/internal/configvalidator/exactly_one_of_test.go index 4daa569..82aa179 100644 --- a/internal/configvalidator/exactly_one_of_test.go +++ b/internal/configvalidator/exactly_one_of_test.go @@ -11,8 +11,8 @@ import ( "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" ) @@ -29,11 +29,10 @@ func TestExactlyOneOfValidatorValidate(t *testing.T) { PathExpressions: nil, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -60,11 +59,10 @@ func TestExactlyOneOfValidatorValidate(t *testing.T) { PathExpressions: path.Expressions{}, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -93,11 +91,10 @@ func TestExactlyOneOfValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -130,11 +127,10 @@ func TestExactlyOneOfValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -173,15 +169,13 @@ func TestExactlyOneOfValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -212,15 +206,13 @@ func TestExactlyOneOfValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -246,15 +238,13 @@ func TestExactlyOneOfValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -281,19 +271,16 @@ func TestExactlyOneOfValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -322,19 +309,16 @@ func TestExactlyOneOfValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -363,19 +347,16 @@ func TestExactlyOneOfValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -409,19 +390,16 @@ func TestExactlyOneOfValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -450,19 +428,16 @@ func TestExactlyOneOfValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -498,23 +473,19 @@ func TestExactlyOneOfValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test3": { + "test3": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -552,23 +523,19 @@ func TestExactlyOneOfValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test3": { + "test3": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -606,23 +573,19 @@ func TestExactlyOneOfValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test3": { + "test3": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -684,15 +647,13 @@ func TestExactlyOneOfValidatorValidateDataSource(t *testing.T) { }, req: datasource.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -721,19 +682,16 @@ func TestExactlyOneOfValidatorValidateDataSource(t *testing.T) { }, req: datasource.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -798,15 +756,13 @@ func TestExactlyOneOfValidatorValidateProvider(t *testing.T) { }, req: provider.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -835,19 +791,16 @@ func TestExactlyOneOfValidatorValidateProvider(t *testing.T) { }, req: provider.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -912,15 +865,13 @@ func TestExactlyOneOfValidatorValidateResource(t *testing.T) { }, req: resource.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -949,19 +900,16 @@ func TestExactlyOneOfValidatorValidateResource(t *testing.T) { }, req: resource.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, diff --git a/internal/configvalidator/required_together_test.go b/internal/configvalidator/required_together_test.go index 93d162f..fd506be 100644 --- a/internal/configvalidator/required_together_test.go +++ b/internal/configvalidator/required_together_test.go @@ -11,8 +11,8 @@ import ( "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" ) @@ -29,11 +29,10 @@ func TestRequiredTogetherValidatorValidate(t *testing.T) { PathExpressions: nil, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -55,11 +54,10 @@ func TestRequiredTogetherValidatorValidate(t *testing.T) { PathExpressions: path.Expressions{}, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -83,11 +81,10 @@ func TestRequiredTogetherValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -120,11 +117,10 @@ func TestRequiredTogetherValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -163,15 +159,13 @@ func TestRequiredTogetherValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -197,15 +191,13 @@ func TestRequiredTogetherValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -231,15 +223,13 @@ func TestRequiredTogetherValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -266,19 +256,16 @@ func TestRequiredTogetherValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -313,19 +300,16 @@ func TestRequiredTogetherValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -354,19 +338,16 @@ func TestRequiredTogetherValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -395,19 +376,16 @@ func TestRequiredTogetherValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -436,19 +414,16 @@ func TestRequiredTogetherValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -478,23 +453,19 @@ func TestRequiredTogetherValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test3": { + "test3": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -532,23 +503,19 @@ func TestRequiredTogetherValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test3": { + "test3": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -580,23 +547,19 @@ func TestRequiredTogetherValidatorValidate(t *testing.T) { }, }, config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test3": { + "test3": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -652,15 +615,13 @@ func TestRequiredTogetherValidatorValidateDataSource(t *testing.T) { }, req: datasource.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -689,19 +650,16 @@ func TestRequiredTogetherValidatorValidateDataSource(t *testing.T) { }, req: datasource.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -766,15 +724,13 @@ func TestRequiredTogetherValidatorValidateProvider(t *testing.T) { }, req: provider.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -803,19 +759,16 @@ func TestRequiredTogetherValidatorValidateProvider(t *testing.T) { }, req: provider.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -880,15 +833,13 @@ func TestRequiredTogetherValidatorValidateResource(t *testing.T) { }, req: resource.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -917,19 +868,16 @@ func TestRequiredTogetherValidatorValidateResource(t *testing.T) { }, req: resource.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, diff --git a/internal/primitivevalidator/acceptable_values_validator.go b/internal/primitivevalidator/acceptable_values_validator.go deleted file mode 100644 index 9312afd..0000000 --- a/internal/primitivevalidator/acceptable_values_validator.go +++ /dev/null @@ -1,70 +0,0 @@ -package primitivevalidator - -import ( - "context" - "fmt" - - "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" -) - -// acceptablePrimitiveValuesAttributeValidator is the underlying struct implementing OneOf and NoneOf. -type acceptablePrimitiveValuesAttributeValidator struct { - acceptableValues []attr.Value - shouldMatch bool -} - -var _ tfsdk.AttributeValidator = (*acceptablePrimitiveValuesAttributeValidator)(nil) - -func (av *acceptablePrimitiveValuesAttributeValidator) Description(ctx context.Context) string { - return av.MarkdownDescription(ctx) -} - -func (av *acceptablePrimitiveValuesAttributeValidator) MarkdownDescription(_ context.Context) string { - if av.shouldMatch { - return fmt.Sprintf("Value must be one of: %q", av.acceptableValues) - } else { - return fmt.Sprintf("Value must be none of: %q", av.acceptableValues) - } - -} - -func (av *acceptablePrimitiveValuesAttributeValidator) Validate(ctx context.Context, req tfsdk.ValidateAttributeRequest, res *tfsdk.ValidateAttributeResponse) { - if req.AttributeConfig.IsNull() || req.AttributeConfig.IsUnknown() { - return - } - - var value attr.Value - switch typedAttributeConfig := req.AttributeConfig.(type) { - case types.String, types.Bool, types.Int64, types.Float64, types.Number: - value = typedAttributeConfig - default: - res.Diagnostics.AddAttributeError( - req.AttributePath, - "This validator should be used against primitive types (String, Bool, Number, Int64, Float64).", - "This is always indicative of a bug within the provider.", - ) - return - } - - if av.shouldMatch && !av.isAcceptableValue(value) || //< EITHER should match but it does not - !av.shouldMatch && av.isAcceptableValue(value) { //< OR should not match but it does - res.Diagnostics.Append(validatordiag.InvalidAttributeValueMatchDiagnostic( - req.AttributePath, - av.Description(ctx), - value.String(), - )) - } -} - -func (av *acceptablePrimitiveValuesAttributeValidator) isAcceptableValue(v attr.Value) bool { - for _, acceptableV := range av.acceptableValues { - if v.Equal(acceptableV) { - return true - } - } - - return false -} diff --git a/internal/primitivevalidator/none_of.go b/internal/primitivevalidator/none_of.go deleted file mode 100644 index cca7c80..0000000 --- a/internal/primitivevalidator/none_of.go +++ /dev/null @@ -1,19 +0,0 @@ -package primitivevalidator - -import ( - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" -) - -// NoneOf checks that the value held in the attribute -// is none of the given `unacceptableValues`. -// -// This validator can be used only against primitives like -// `types.String`, `types.Number`, `types.Int64`, -// `types.Float64` and `types.Bool`. -func NoneOf(unacceptableValues ...attr.Value) tfsdk.AttributeValidator { - return &acceptablePrimitiveValuesAttributeValidator{ - acceptableValues: unacceptableValues, - shouldMatch: false, - } -} diff --git a/internal/primitivevalidator/none_of_test.go b/internal/primitivevalidator/none_of_test.go deleted file mode 100644 index 5aeb813..0000000 --- a/internal/primitivevalidator/none_of_test.go +++ /dev/null @@ -1,193 +0,0 @@ -package primitivevalidator_test - -import ( - "context" - "math" - "math/big" - "testing" - - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" - - "github.com/hashicorp/terraform-plugin-framework-validators/internal/primitivevalidator" -) - -func TestNoneOfValidator(t *testing.T) { - t.Parallel() - - type testCase struct { - in attr.Value - validator tfsdk.AttributeValidator - expErrors int - } - - objPersonAttrTypes := map[string]attr.Type{ - "Name": types.StringType, - "Age": types.Int64Type, - } - objAttrTypes := map[string]attr.Type{ - "Person": types.ObjectType{ - AttrTypes: objPersonAttrTypes, - }, - "Address": types.StringType, - } - - testCases := map[string]testCase{ - "simple-match": { - in: types.StringValue("foo"), - validator: primitivevalidator.NoneOf( - types.StringValue("foo"), - types.StringValue("bar"), - types.StringValue("baz"), - ), - expErrors: 1, - }, - "simple-mismatch": { - in: types.StringValue("foz"), - validator: primitivevalidator.NoneOf( - types.StringValue("foo"), - types.StringValue("bar"), - types.StringValue("baz"), - ), - expErrors: 0, - }, - "mixed": { - in: types.Float64Value(1.234), - validator: primitivevalidator.NoneOf( - types.StringValue("foo"), - types.Int64Value(567), - types.Float64Value(1.234), - ), - expErrors: 1, - }, - "list-not-allowed": { - in: types.ListValueMust( - types.Int64Type, - []attr.Value{ - types.Int64Value(10), - types.Int64Value(20), - types.Int64Value(30), - }, - ), - validator: primitivevalidator.NoneOf( - types.Int64Value(10), - types.Int64Value(20), - types.Int64Value(30), - types.Int64Value(40), - types.Int64Value(50), - ), - expErrors: 1, - }, - "set-not-allowed": { - in: types.SetValueMust( - types.StringType, - []attr.Value{ - types.StringValue("foo"), - types.StringValue("bar"), - types.StringValue("baz"), - }, - ), - validator: primitivevalidator.NoneOf( - types.StringValue("bob"), - types.StringValue("alice"), - types.StringValue("john"), - types.StringValue("foo"), - types.StringValue("bar"), - types.StringValue("baz"), - ), - expErrors: 1, - }, - "map-not-allowed": { - in: types.MapValueMust( - types.NumberType, - map[string]attr.Value{ - "one.one": types.NumberValue(big.NewFloat(1.1)), - "ten.twenty": types.NumberValue(big.NewFloat(10.20)), - "five.four": types.NumberValue(big.NewFloat(5.4)), - }, - ), - validator: primitivevalidator.NoneOf( - types.NumberValue(big.NewFloat(1.1)), - types.NumberValue(big.NewFloat(math.MaxFloat64)), - types.NumberValue(big.NewFloat(math.SmallestNonzeroFloat64)), - types.NumberValue(big.NewFloat(10.20)), - types.NumberValue(big.NewFloat(5.4)), - ), - expErrors: 1, - }, - "object-not-allowed": { - in: types.ObjectValueMust( - objAttrTypes, - map[string]attr.Value{ - "Person": types.ObjectValueMust( - objPersonAttrTypes, - map[string]attr.Value{ - "Name": types.StringValue("Bob Parr"), - "Age": types.Int64Value(40), - }, - ), - "Address": types.StringValue("1200 Park Avenue Emeryville"), - }, - ), - validator: primitivevalidator.NoneOf( - types.ObjectValueMust( - map[string]attr.Type{}, - map[string]attr.Value{}, - ), - types.ObjectValueMust( - objPersonAttrTypes, - map[string]attr.Value{ - "Name": types.StringValue("Bob Parr"), - "Age": types.Int64Value(40), - }, - ), - types.StringValue("1200 Park Avenue Emeryville"), - types.Int64Value(123), - types.StringValue("Bob Parr"), - ), - expErrors: 1, - }, - "skip-validation-on-null": { - in: types.StringNull(), - validator: primitivevalidator.NoneOf( - types.StringValue("foo"), - types.StringValue("bar"), - types.StringValue("baz"), - ), - expErrors: 0, - }, - "skip-validation-on-unknown": { - in: types.StringUnknown(), - validator: primitivevalidator.NoneOf( - types.StringValue("foo"), - types.StringValue("bar"), - types.StringValue("baz"), - ), - expErrors: 0, - }, - } - - for name, test := range testCases { - name, test := name, test - t.Run(name, func(t *testing.T) { - req := tfsdk.ValidateAttributeRequest{ - AttributeConfig: test.in, - } - res := tfsdk.ValidateAttributeResponse{} - test.validator.Validate(context.TODO(), req, &res) - - if test.expErrors > 0 && !res.Diagnostics.HasError() { - t.Fatalf("expected %d error(s), got none", test.expErrors) - } - - if test.expErrors > 0 && test.expErrors != res.Diagnostics.ErrorsCount() { - t.Fatalf("expected %d error(s), got %d: %v", test.expErrors, res.Diagnostics.ErrorsCount(), res.Diagnostics) - } - - if test.expErrors == 0 && res.Diagnostics.HasError() { - t.Fatalf("expected no error(s), got %d: %v", res.Diagnostics.ErrorsCount(), res.Diagnostics) - } - }) - } -} diff --git a/internal/primitivevalidator/one_of.go b/internal/primitivevalidator/one_of.go deleted file mode 100644 index 7bacc6d..0000000 --- a/internal/primitivevalidator/one_of.go +++ /dev/null @@ -1,19 +0,0 @@ -package primitivevalidator - -import ( - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" -) - -// OneOf checks that the value held in the attribute -// is one of the given `acceptableValues`. -// -// This validator can be used only against primitives like -// `types.String`, `types.Number`, `types.Int64`, -// `types.Float64` and `types.Bool`. -func OneOf(acceptableValues ...attr.Value) tfsdk.AttributeValidator { - return &acceptablePrimitiveValuesAttributeValidator{ - acceptableValues: acceptableValues, - shouldMatch: true, - } -} diff --git a/internal/primitivevalidator/one_of_test.go b/internal/primitivevalidator/one_of_test.go deleted file mode 100644 index 162ca60..0000000 --- a/internal/primitivevalidator/one_of_test.go +++ /dev/null @@ -1,193 +0,0 @@ -package primitivevalidator_test - -import ( - "context" - "math" - "math/big" - "testing" - - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" - - "github.com/hashicorp/terraform-plugin-framework-validators/internal/primitivevalidator" -) - -func TestOneOfValidator(t *testing.T) { - t.Parallel() - - type testCase struct { - in attr.Value - validator tfsdk.AttributeValidator - expErrors int - } - - objPersonAttrTypes := map[string]attr.Type{ - "Name": types.StringType, - "Age": types.Int64Type, - } - objAttrTypes := map[string]attr.Type{ - "Person": types.ObjectType{ - AttrTypes: objPersonAttrTypes, - }, - "Address": types.StringType, - } - - testCases := map[string]testCase{ - "simple-match": { - in: types.StringValue("foo"), - validator: primitivevalidator.OneOf( - types.StringValue("foo"), - types.StringValue("bar"), - types.StringValue("baz"), - ), - expErrors: 0, - }, - "simple-mismatch": { - in: types.StringValue("foz"), - validator: primitivevalidator.OneOf( - types.StringValue("foo"), - types.StringValue("bar"), - types.StringValue("baz"), - ), - expErrors: 1, - }, - "mixed": { - in: types.Float64Value(1.234), - validator: primitivevalidator.OneOf( - types.StringValue("foo"), - types.Int64Value(567), - types.Float64Value(1.234), - ), - expErrors: 0, - }, - "list-not-allowed": { - in: types.ListValueMust( - types.Int64Type, - []attr.Value{ - types.Int64Value(10), - types.Int64Value(20), - types.Int64Value(30), - }, - ), - validator: primitivevalidator.OneOf( - types.Int64Value(10), - types.Int64Value(20), - types.Int64Value(30), - types.Int64Value(40), - types.Int64Value(50), - ), - expErrors: 1, - }, - "set-not-allowed": { - in: types.SetValueMust( - types.StringType, - []attr.Value{ - types.StringValue("foo"), - types.StringValue("bar"), - types.StringValue("baz"), - }, - ), - validator: primitivevalidator.OneOf( - types.StringValue("bob"), - types.StringValue("alice"), - types.StringValue("john"), - types.StringValue("foo"), - types.StringValue("bar"), - types.StringValue("baz"), - ), - expErrors: 1, - }, - "map-not-allowed": { - in: types.MapValueMust( - types.NumberType, - map[string]attr.Value{ - "one.one": types.NumberValue(big.NewFloat(1.1)), - "ten.twenty": types.NumberValue(big.NewFloat(10.20)), - "five.four": types.NumberValue(big.NewFloat(5.4)), - }, - ), - validator: primitivevalidator.OneOf( - types.NumberValue(big.NewFloat(1.1)), - types.NumberValue(big.NewFloat(math.MaxFloat64)), - types.NumberValue(big.NewFloat(math.SmallestNonzeroFloat64)), - types.NumberValue(big.NewFloat(10.20)), - types.NumberValue(big.NewFloat(5.4)), - ), - expErrors: 1, - }, - "object-not-allowed": { - in: types.ObjectValueMust( - objAttrTypes, - map[string]attr.Value{ - "Person": types.ObjectValueMust( - objPersonAttrTypes, - map[string]attr.Value{ - "Name": types.StringValue("Bob Parr"), - "Age": types.Int64Value(40), - }, - ), - "Address": types.StringValue("1200 Park Avenue Emeryville"), - }, - ), - validator: primitivevalidator.OneOf( - types.ObjectValueMust( - map[string]attr.Type{}, - map[string]attr.Value{}, - ), - types.ObjectValueMust( - objPersonAttrTypes, - map[string]attr.Value{ - "Name": types.StringValue("Bob Parr"), - "Age": types.Int64Value(40), - }, - ), - types.StringValue("1200 Park Avenue Emeryville"), - types.Int64Value(123), - types.StringValue("Bob Parr"), - ), - expErrors: 1, - }, - "skip-validation-on-null": { - in: types.StringNull(), - validator: primitivevalidator.OneOf( - types.StringValue("foo"), - types.StringValue("bar"), - types.StringValue("baz"), - ), - expErrors: 0, - }, - "skip-validation-on-unknown": { - in: types.StringUnknown(), - validator: primitivevalidator.OneOf( - types.StringValue("foo"), - types.StringValue("bar"), - types.StringValue("baz"), - ), - expErrors: 0, - }, - } - - for name, test := range testCases { - name, test := name, test - t.Run(name, func(t *testing.T) { - req := tfsdk.ValidateAttributeRequest{ - AttributeConfig: test.in, - } - res := tfsdk.ValidateAttributeResponse{} - test.validator.Validate(context.TODO(), req, &res) - - if test.expErrors > 0 && !res.Diagnostics.HasError() { - t.Fatalf("expected %d error(s), got none", test.expErrors) - } - - if test.expErrors > 0 && test.expErrors != res.Diagnostics.ErrorsCount() { - t.Fatalf("expected %d error(s), got %d: %v", test.expErrors, res.Diagnostics.ErrorsCount(), res.Diagnostics) - } - - if test.expErrors == 0 && res.Diagnostics.HasError() { - t.Fatalf("expected no error(s), got %d: %v", res.Diagnostics.ErrorsCount(), res.Diagnostics) - } - }) - } -} diff --git a/internal/schemavalidator/also_requires.go b/internal/schemavalidator/also_requires.go new file mode 100644 index 0000000..6696f7a --- /dev/null +++ b/internal/schemavalidator/also_requires.go @@ -0,0 +1,225 @@ +package schemavalidator + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" +) + +// This type of validator must satisfy all types. +var ( + _ validator.Bool = AlsoRequiresValidator{} + _ validator.Float64 = AlsoRequiresValidator{} + _ validator.Int64 = AlsoRequiresValidator{} + _ validator.List = AlsoRequiresValidator{} + _ validator.Map = AlsoRequiresValidator{} + _ validator.Number = AlsoRequiresValidator{} + _ validator.Object = AlsoRequiresValidator{} + _ validator.Set = AlsoRequiresValidator{} + _ validator.String = AlsoRequiresValidator{} +) + +// AlsoRequiresValidator is the underlying struct implementing AlsoRequires. +type AlsoRequiresValidator struct { + PathExpressions path.Expressions +} + +type AlsoRequiresValidatorRequest struct { + Config tfsdk.Config + ConfigValue attr.Value + Path path.Path + PathExpression path.Expression +} + +type AlsoRequiresValidatorResponse struct { + Diagnostics diag.Diagnostics +} + +func (av AlsoRequiresValidator) Description(ctx context.Context) string { + return av.MarkdownDescription(ctx) +} + +func (av AlsoRequiresValidator) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("Ensure that if an attribute is set, also these are set: %q", av.PathExpressions) +} + +func (av AlsoRequiresValidator) Validate(ctx context.Context, req AlsoRequiresValidatorRequest, res *AlsoRequiresValidatorResponse) { + // If attribute configuration is null, there is nothing else to validate + if req.ConfigValue.IsNull() { + return + } + + expressions := req.PathExpression.MergeExpressions(av.PathExpressions...) + + for _, expression := range expressions { + matchedPaths, diags := req.Config.PathMatches(ctx, expression) + + res.Diagnostics.Append(diags...) + + // Collect all errors + if diags.HasError() { + continue + } + + for _, mp := range matchedPaths { + // If the user specifies the same attribute this validator is applied to, + // also as part of the input, skip it + if mp.Equal(req.Path) { + continue + } + + var mpVal attr.Value + diags := req.Config.GetAttribute(ctx, mp, &mpVal) + res.Diagnostics.Append(diags...) + + // Collect all errors + if diags.HasError() { + continue + } + + // Delay validation until all involved attribute have a known value + if mpVal.IsUnknown() { + return + } + + if mpVal.IsNull() { + res.Diagnostics.Append(validatordiag.InvalidAttributeCombinationDiagnostic( + req.Path, + fmt.Sprintf("Attribute %q must be specified when %q is specified", mp, req.Path), + )) + } + } + } +} + +func (av AlsoRequiresValidator) ValidateBool(ctx context.Context, req validator.BoolRequest, resp *validator.BoolResponse) { + validateReq := AlsoRequiresValidatorRequest{ + Config: req.Config, + ConfigValue: req.ConfigValue, + Path: req.Path, + PathExpression: req.PathExpression, + } + validateResp := &AlsoRequiresValidatorResponse{} + + av.Validate(ctx, validateReq, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) +} + +func (av AlsoRequiresValidator) ValidateFloat64(ctx context.Context, req validator.Float64Request, resp *validator.Float64Response) { + validateReq := AlsoRequiresValidatorRequest{ + Config: req.Config, + ConfigValue: req.ConfigValue, + Path: req.Path, + PathExpression: req.PathExpression, + } + validateResp := &AlsoRequiresValidatorResponse{} + + av.Validate(ctx, validateReq, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) +} + +func (av AlsoRequiresValidator) ValidateInt64(ctx context.Context, req validator.Int64Request, resp *validator.Int64Response) { + validateReq := AlsoRequiresValidatorRequest{ + Config: req.Config, + ConfigValue: req.ConfigValue, + Path: req.Path, + PathExpression: req.PathExpression, + } + validateResp := &AlsoRequiresValidatorResponse{} + + av.Validate(ctx, validateReq, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) +} + +func (av AlsoRequiresValidator) ValidateList(ctx context.Context, req validator.ListRequest, resp *validator.ListResponse) { + validateReq := AlsoRequiresValidatorRequest{ + Config: req.Config, + ConfigValue: req.ConfigValue, + Path: req.Path, + PathExpression: req.PathExpression, + } + validateResp := &AlsoRequiresValidatorResponse{} + + av.Validate(ctx, validateReq, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) +} + +func (av AlsoRequiresValidator) ValidateMap(ctx context.Context, req validator.MapRequest, resp *validator.MapResponse) { + validateReq := AlsoRequiresValidatorRequest{ + Config: req.Config, + ConfigValue: req.ConfigValue, + Path: req.Path, + PathExpression: req.PathExpression, + } + validateResp := &AlsoRequiresValidatorResponse{} + + av.Validate(ctx, validateReq, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) +} + +func (av AlsoRequiresValidator) ValidateNumber(ctx context.Context, req validator.NumberRequest, resp *validator.NumberResponse) { + validateReq := AlsoRequiresValidatorRequest{ + Config: req.Config, + ConfigValue: req.ConfigValue, + Path: req.Path, + PathExpression: req.PathExpression, + } + validateResp := &AlsoRequiresValidatorResponse{} + + av.Validate(ctx, validateReq, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) +} + +func (av AlsoRequiresValidator) ValidateObject(ctx context.Context, req validator.ObjectRequest, resp *validator.ObjectResponse) { + validateReq := AlsoRequiresValidatorRequest{ + Config: req.Config, + ConfigValue: req.ConfigValue, + Path: req.Path, + PathExpression: req.PathExpression, + } + validateResp := &AlsoRequiresValidatorResponse{} + + av.Validate(ctx, validateReq, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) +} + +func (av AlsoRequiresValidator) ValidateSet(ctx context.Context, req validator.SetRequest, resp *validator.SetResponse) { + validateReq := AlsoRequiresValidatorRequest{ + Config: req.Config, + ConfigValue: req.ConfigValue, + Path: req.Path, + PathExpression: req.PathExpression, + } + validateResp := &AlsoRequiresValidatorResponse{} + + av.Validate(ctx, validateReq, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) +} + +func (av AlsoRequiresValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { + validateReq := AlsoRequiresValidatorRequest{ + Config: req.Config, + ConfigValue: req.ConfigValue, + Path: req.Path, + PathExpression: req.PathExpression, + } + validateResp := &AlsoRequiresValidatorResponse{} + + av.Validate(ctx, validateReq, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) +} diff --git a/schemavalidator/also_requires_test.go b/internal/schemavalidator/also_requires_test.go similarity index 59% rename from schemavalidator/also_requires_test.go rename to internal/schemavalidator/also_requires_test.go index 179fd06..15597be 100644 --- a/schemavalidator/also_requires_test.go +++ b/internal/schemavalidator/also_requires_test.go @@ -5,37 +5,34 @@ import ( "testing" "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/tfsdk" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" - "github.com/hashicorp/terraform-plugin-framework-validators/schemavalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" ) -func TestRequiredWithValidator(t *testing.T) { +func TestAlsoRequiresValidatorValidate(t *testing.T) { t.Parallel() type testCase struct { - req tfsdk.ValidateAttributeRequest + req schemavalidator.AlsoRequiresValidatorRequest in path.Expressions expErrors int } testCases := map[string]testCase{ "base": { - req: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.StringValue("bar value"), - AttributePath: path.Root("bar"), - AttributePathExpression: path.MatchRoot("bar"), + req: schemavalidator.AlsoRequiresValidatorRequest{ + ConfigValue: types.StringValue("bar value"), + Path: path.Root("bar"), + PathExpression: path.MatchRoot("bar"), Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "foo": { - Type: types.Int64Type, - }, - "bar": { - Type: types.StringType, - }, + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "foo": schema.Int64Attribute{}, + "bar": schema.StringAttribute{}, }, }, Raw: tftypes.NewValue(tftypes.Object{ @@ -54,19 +51,15 @@ func TestRequiredWithValidator(t *testing.T) { }, }, "self-is-null": { - req: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.StringNull(), - AttributePath: path.Root("bar"), - AttributePathExpression: path.MatchRoot("bar"), + req: schemavalidator.AlsoRequiresValidatorRequest{ + ConfigValue: types.StringNull(), + Path: path.Root("bar"), + PathExpression: path.MatchRoot("bar"), Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "foo": { - Type: types.Int64Type, - }, - "bar": { - Type: types.StringType, - }, + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "foo": schema.Int64Attribute{}, + "bar": schema.StringAttribute{}, }, }, Raw: tftypes.NewValue(tftypes.Object{ @@ -85,22 +78,16 @@ func TestRequiredWithValidator(t *testing.T) { }, }, "error_missing-one": { - req: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.StringValue("bar value"), - AttributePath: path.Root("bar"), - AttributePathExpression: path.MatchRoot("bar"), + req: schemavalidator.AlsoRequiresValidatorRequest{ + ConfigValue: types.StringValue("bar value"), + Path: path.Root("bar"), + PathExpression: path.MatchRoot("bar"), Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "foo": { - Type: types.Int64Type, - }, - "bar": { - Type: types.StringType, - }, - "baz": { - Type: types.Int64Type, - }, + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "foo": schema.Int64Attribute{}, + "bar": schema.StringAttribute{}, + "baz": schema.Int64Attribute{}, }, }, Raw: tftypes.NewValue(tftypes.Object{ @@ -123,22 +110,16 @@ func TestRequiredWithValidator(t *testing.T) { expErrors: 1, }, "error_missing-two": { - req: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.StringValue("bar value"), - AttributePath: path.Root("bar"), - AttributePathExpression: path.MatchRoot("bar"), + req: schemavalidator.AlsoRequiresValidatorRequest{ + ConfigValue: types.StringValue("bar value"), + Path: path.Root("bar"), + PathExpression: path.MatchRoot("bar"), Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "foo": { - Type: types.Int64Type, - }, - "bar": { - Type: types.StringType, - }, - "baz": { - Type: types.Int64Type, - }, + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "foo": schema.Int64Attribute{}, + "bar": schema.StringAttribute{}, + "baz": schema.Int64Attribute{}, }, }, Raw: tftypes.NewValue(tftypes.Object{ @@ -161,22 +142,16 @@ func TestRequiredWithValidator(t *testing.T) { expErrors: 2, }, "allow-duplicate-input": { - req: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.StringValue("bar value"), - AttributePath: path.Root("bar"), - AttributePathExpression: path.MatchRoot("bar"), + req: schemavalidator.AlsoRequiresValidatorRequest{ + ConfigValue: types.StringValue("bar value"), + Path: path.Root("bar"), + PathExpression: path.MatchRoot("bar"), Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "foo": { - Type: types.Int64Type, - }, - "bar": { - Type: types.StringType, - }, - "baz": { - Type: types.Int64Type, - }, + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "foo": schema.Int64Attribute{}, + "bar": schema.StringAttribute{}, + "baz": schema.Int64Attribute{}, }, }, Raw: tftypes.NewValue(tftypes.Object{ @@ -199,22 +174,16 @@ func TestRequiredWithValidator(t *testing.T) { }, }, "unknowns": { - req: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.StringValue("bar value"), - AttributePath: path.Root("bar"), - AttributePathExpression: path.MatchRoot("bar"), + req: schemavalidator.AlsoRequiresValidatorRequest{ + ConfigValue: types.StringValue("bar value"), + Path: path.Root("bar"), + PathExpression: path.MatchRoot("bar"), Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "foo": { - Type: types.Int64Type, - }, - "bar": { - Type: types.StringType, - }, - "baz": { - Type: types.Int64Type, - }, + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "foo": schema.Int64Attribute{}, + "bar": schema.StringAttribute{}, + "baz": schema.Int64Attribute{}, }, }, Raw: tftypes.NewValue(tftypes.Object{ @@ -236,19 +205,15 @@ func TestRequiredWithValidator(t *testing.T) { }, }, "matches-no-attribute-in-schema": { - req: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.StringValue("bar value"), - AttributePath: path.Root("bar"), - AttributePathExpression: path.MatchRoot("bar"), + req: schemavalidator.AlsoRequiresValidatorRequest{ + ConfigValue: types.StringValue("bar value"), + Path: path.Root("bar"), + PathExpression: path.MatchRoot("bar"), Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "foo": { - Type: types.Int64Type, - }, - "bar": { - Type: types.StringType, - }, + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "foo": schema.Int64Attribute{}, + "bar": schema.StringAttribute{}, }, }, Raw: tftypes.NewValue(tftypes.Object{ @@ -271,9 +236,11 @@ func TestRequiredWithValidator(t *testing.T) { for name, test := range testCases { t.Run(name, func(t *testing.T) { - res := tfsdk.ValidateAttributeResponse{} + res := &schemavalidator.AlsoRequiresValidatorResponse{} - schemavalidator.AlsoRequires(test.in...).Validate(context.TODO(), test.req, &res) + schemavalidator.AlsoRequiresValidator{ + PathExpressions: test.in, + }.Validate(context.TODO(), test.req, res) if test.expErrors > 0 && !res.Diagnostics.HasError() { t.Fatal("expected error(s), got none") diff --git a/internal/schemavalidator/at_least_one_of.go b/internal/schemavalidator/at_least_one_of.go new file mode 100644 index 0000000..d98bc87 --- /dev/null +++ b/internal/schemavalidator/at_least_one_of.go @@ -0,0 +1,221 @@ +package schemavalidator + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" +) + +// This type of validator must satisfy all types. +var ( + _ validator.Bool = AtLeastOneOfValidator{} + _ validator.Float64 = AtLeastOneOfValidator{} + _ validator.Int64 = AtLeastOneOfValidator{} + _ validator.List = AtLeastOneOfValidator{} + _ validator.Map = AtLeastOneOfValidator{} + _ validator.Number = AtLeastOneOfValidator{} + _ validator.Object = AtLeastOneOfValidator{} + _ validator.Set = AtLeastOneOfValidator{} + _ validator.String = AtLeastOneOfValidator{} +) + +// AtLeastOneOfValidator is the underlying struct implementing AtLeastOneOf. +type AtLeastOneOfValidator struct { + PathExpressions path.Expressions +} + +type AtLeastOneOfValidatorRequest struct { + Config tfsdk.Config + ConfigValue attr.Value + Path path.Path + PathExpression path.Expression +} + +type AtLeastOneOfValidatorResponse struct { + Diagnostics diag.Diagnostics +} + +func (av AtLeastOneOfValidator) Description(ctx context.Context) string { + return av.MarkdownDescription(ctx) +} + +func (av AtLeastOneOfValidator) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("Ensure that at least one attribute from this collection is set: %s", av.PathExpressions) +} + +func (av AtLeastOneOfValidator) Validate(ctx context.Context, req AtLeastOneOfValidatorRequest, res *AtLeastOneOfValidatorResponse) { + // If attribute configuration is not null, validator already succeeded. + if !req.ConfigValue.IsNull() { + return + } + + expressions := req.PathExpression.MergeExpressions(av.PathExpressions...) + + for _, expression := range expressions { + matchedPaths, diags := req.Config.PathMatches(ctx, expression) + + res.Diagnostics.Append(diags...) + + // Collect all errors + if diags.HasError() { + continue + } + + for _, mp := range matchedPaths { + var mpVal attr.Value + diags := req.Config.GetAttribute(ctx, mp, &mpVal) + res.Diagnostics.Append(diags...) + + // Collect all errors + if diags.HasError() { + continue + } + + // Delay validation until all involved attribute have a known value + if mpVal.IsUnknown() { + return + } + + if !mpVal.IsNull() { + return + } + } + } + + res.Diagnostics.Append(validatordiag.InvalidAttributeCombinationDiagnostic( + req.Path, + fmt.Sprintf("At least one attribute out of %s must be specified", expressions), + )) +} + +func (av AtLeastOneOfValidator) ValidateBool(ctx context.Context, req validator.BoolRequest, resp *validator.BoolResponse) { + validateReq := AtLeastOneOfValidatorRequest{ + Config: req.Config, + ConfigValue: req.ConfigValue, + Path: req.Path, + PathExpression: req.PathExpression, + } + validateResp := &AtLeastOneOfValidatorResponse{} + + av.Validate(ctx, validateReq, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) +} + +func (av AtLeastOneOfValidator) ValidateFloat64(ctx context.Context, req validator.Float64Request, resp *validator.Float64Response) { + validateReq := AtLeastOneOfValidatorRequest{ + Config: req.Config, + ConfigValue: req.ConfigValue, + Path: req.Path, + PathExpression: req.PathExpression, + } + validateResp := &AtLeastOneOfValidatorResponse{} + + av.Validate(ctx, validateReq, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) +} + +func (av AtLeastOneOfValidator) ValidateInt64(ctx context.Context, req validator.Int64Request, resp *validator.Int64Response) { + validateReq := AtLeastOneOfValidatorRequest{ + Config: req.Config, + ConfigValue: req.ConfigValue, + Path: req.Path, + PathExpression: req.PathExpression, + } + validateResp := &AtLeastOneOfValidatorResponse{} + + av.Validate(ctx, validateReq, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) +} + +func (av AtLeastOneOfValidator) ValidateList(ctx context.Context, req validator.ListRequest, resp *validator.ListResponse) { + validateReq := AtLeastOneOfValidatorRequest{ + Config: req.Config, + ConfigValue: req.ConfigValue, + Path: req.Path, + PathExpression: req.PathExpression, + } + validateResp := &AtLeastOneOfValidatorResponse{} + + av.Validate(ctx, validateReq, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) +} + +func (av AtLeastOneOfValidator) ValidateMap(ctx context.Context, req validator.MapRequest, resp *validator.MapResponse) { + validateReq := AtLeastOneOfValidatorRequest{ + Config: req.Config, + ConfigValue: req.ConfigValue, + Path: req.Path, + PathExpression: req.PathExpression, + } + validateResp := &AtLeastOneOfValidatorResponse{} + + av.Validate(ctx, validateReq, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) +} + +func (av AtLeastOneOfValidator) ValidateNumber(ctx context.Context, req validator.NumberRequest, resp *validator.NumberResponse) { + validateReq := AtLeastOneOfValidatorRequest{ + Config: req.Config, + ConfigValue: req.ConfigValue, + Path: req.Path, + PathExpression: req.PathExpression, + } + validateResp := &AtLeastOneOfValidatorResponse{} + + av.Validate(ctx, validateReq, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) +} + +func (av AtLeastOneOfValidator) ValidateObject(ctx context.Context, req validator.ObjectRequest, resp *validator.ObjectResponse) { + validateReq := AtLeastOneOfValidatorRequest{ + Config: req.Config, + ConfigValue: req.ConfigValue, + Path: req.Path, + PathExpression: req.PathExpression, + } + validateResp := &AtLeastOneOfValidatorResponse{} + + av.Validate(ctx, validateReq, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) +} + +func (av AtLeastOneOfValidator) ValidateSet(ctx context.Context, req validator.SetRequest, resp *validator.SetResponse) { + validateReq := AtLeastOneOfValidatorRequest{ + Config: req.Config, + ConfigValue: req.ConfigValue, + Path: req.Path, + PathExpression: req.PathExpression, + } + validateResp := &AtLeastOneOfValidatorResponse{} + + av.Validate(ctx, validateReq, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) +} + +func (av AtLeastOneOfValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { + validateReq := AtLeastOneOfValidatorRequest{ + Config: req.Config, + ConfigValue: req.ConfigValue, + Path: req.Path, + PathExpression: req.PathExpression, + } + validateResp := &AtLeastOneOfValidatorResponse{} + + av.Validate(ctx, validateReq, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) +} diff --git a/schemavalidator/at_least_one_of_test.go b/internal/schemavalidator/at_least_one_of_test.go similarity index 59% rename from schemavalidator/at_least_one_of_test.go rename to internal/schemavalidator/at_least_one_of_test.go index d2f0be0..b9d8235 100644 --- a/schemavalidator/at_least_one_of_test.go +++ b/internal/schemavalidator/at_least_one_of_test.go @@ -5,37 +5,34 @@ import ( "testing" "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/tfsdk" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" - "github.com/hashicorp/terraform-plugin-framework-validators/schemavalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" ) -func TestAtLeastOneOfValidator(t *testing.T) { +func TestAtLeastOneOfValidatorValidate(t *testing.T) { t.Parallel() type testCase struct { - req tfsdk.ValidateAttributeRequest + req schemavalidator.AtLeastOneOfValidatorRequest in path.Expressions expErrors int } testCases := map[string]testCase{ "base": { - req: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.StringValue("bar value"), - AttributePath: path.Root("bar"), - AttributePathExpression: path.MatchRoot("bar"), + req: schemavalidator.AtLeastOneOfValidatorRequest{ + ConfigValue: types.StringValue("bar value"), + Path: path.Root("bar"), + PathExpression: path.MatchRoot("bar"), Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "foo": { - Type: types.Int64Type, - }, - "bar": { - Type: types.StringType, - }, + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "foo": schema.Int64Attribute{}, + "bar": schema.StringAttribute{}, }, }, Raw: tftypes.NewValue(tftypes.Object{ @@ -54,19 +51,15 @@ func TestAtLeastOneOfValidator(t *testing.T) { }, }, "self-is-null": { - req: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.StringNull(), - AttributePath: path.Root("bar"), - AttributePathExpression: path.MatchRoot("bar"), + req: schemavalidator.AtLeastOneOfValidatorRequest{ + ConfigValue: types.StringNull(), + Path: path.Root("bar"), + PathExpression: path.MatchRoot("bar"), Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "foo": { - Type: types.Int64Type, - }, - "bar": { - Type: types.StringType, - }, + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "foo": schema.Int64Attribute{}, + "bar": schema.StringAttribute{}, }, }, Raw: tftypes.NewValue(tftypes.Object{ @@ -85,22 +78,16 @@ func TestAtLeastOneOfValidator(t *testing.T) { }, }, "error_none-set": { - req: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.StringNull(), - AttributePath: path.Root("bar"), - AttributePathExpression: path.MatchRoot("bar"), + req: schemavalidator.AtLeastOneOfValidatorRequest{ + ConfigValue: types.StringNull(), + Path: path.Root("bar"), + PathExpression: path.MatchRoot("bar"), Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "foo": { - Type: types.Int64Type, - }, - "bar": { - Type: types.StringType, - }, - "baz": { - Type: types.Int64Type, - }, + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "foo": schema.Int64Attribute{}, + "bar": schema.StringAttribute{}, + "baz": schema.Int64Attribute{}, }, }, Raw: tftypes.NewValue(tftypes.Object{ @@ -123,22 +110,16 @@ func TestAtLeastOneOfValidator(t *testing.T) { expErrors: 1, }, "multiple-set": { - req: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.StringValue("bar value"), - AttributePath: path.Root("bar"), - AttributePathExpression: path.MatchRoot("bar"), + req: schemavalidator.AtLeastOneOfValidatorRequest{ + ConfigValue: types.StringValue("bar value"), + Path: path.Root("bar"), + PathExpression: path.MatchRoot("bar"), Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "foo": { - Type: types.Int64Type, - }, - "bar": { - Type: types.StringType, - }, - "baz": { - Type: types.Float64Type, - }, + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "foo": schema.Int64Attribute{}, + "bar": schema.StringAttribute{}, + "baz": schema.Float64Attribute{}, }, }, Raw: tftypes.NewValue(tftypes.Object{ @@ -160,22 +141,16 @@ func TestAtLeastOneOfValidator(t *testing.T) { }, }, "allow-duplicate-input": { - req: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.StringValue("bar value"), - AttributePath: path.Root("bar"), - AttributePathExpression: path.MatchRoot("bar"), + req: schemavalidator.AtLeastOneOfValidatorRequest{ + ConfigValue: types.StringValue("bar value"), + Path: path.Root("bar"), + PathExpression: path.MatchRoot("bar"), Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "foo": { - Type: types.Int64Type, - }, - "bar": { - Type: types.StringType, - }, - "baz": { - Type: types.Int64Type, - }, + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "foo": schema.Int64Attribute{}, + "bar": schema.StringAttribute{}, + "baz": schema.Int64Attribute{}, }, }, Raw: tftypes.NewValue(tftypes.Object{ @@ -198,22 +173,16 @@ func TestAtLeastOneOfValidator(t *testing.T) { }, }, "unknowns": { - req: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.StringValue("bar value"), - AttributePath: path.Root("bar"), - AttributePathExpression: path.MatchRoot("bar"), + req: schemavalidator.AtLeastOneOfValidatorRequest{ + ConfigValue: types.StringValue("bar value"), + Path: path.Root("bar"), + PathExpression: path.MatchRoot("bar"), Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "foo": { - Type: types.Int64Type, - }, - "bar": { - Type: types.StringType, - }, - "baz": { - Type: types.Int64Type, - }, + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "foo": schema.Int64Attribute{}, + "bar": schema.StringAttribute{}, + "baz": schema.Int64Attribute{}, }, }, Raw: tftypes.NewValue(tftypes.Object{ @@ -235,19 +204,15 @@ func TestAtLeastOneOfValidator(t *testing.T) { }, }, "matches-no-attribute-in-schema": { - req: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.StringNull(), - AttributePath: path.Root("bar"), - AttributePathExpression: path.MatchRoot("bar"), + req: schemavalidator.AtLeastOneOfValidatorRequest{ + ConfigValue: types.StringNull(), + Path: path.Root("bar"), + PathExpression: path.MatchRoot("bar"), Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "foo": { - Type: types.Int64Type, - }, - "bar": { - Type: types.StringType, - }, + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "foo": schema.Int64Attribute{}, + "bar": schema.StringAttribute{}, }, }, Raw: tftypes.NewValue(tftypes.Object{ @@ -270,9 +235,11 @@ func TestAtLeastOneOfValidator(t *testing.T) { for name, test := range testCases { t.Run(name, func(t *testing.T) { - res := tfsdk.ValidateAttributeResponse{} + res := &schemavalidator.AtLeastOneOfValidatorResponse{} - schemavalidator.AtLeastOneOf(test.in...).Validate(context.TODO(), test.req, &res) + schemavalidator.AtLeastOneOfValidator{ + PathExpressions: test.in, + }.Validate(context.TODO(), test.req, res) if test.expErrors > 0 && !res.Diagnostics.HasError() { t.Fatal("expected error(s), got none") diff --git a/internal/schemavalidator/conflicts_with.go b/internal/schemavalidator/conflicts_with.go new file mode 100644 index 0000000..dd06138 --- /dev/null +++ b/internal/schemavalidator/conflicts_with.go @@ -0,0 +1,225 @@ +package schemavalidator + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" +) + +// This type of validator must satisfy all types. +var ( + _ validator.Bool = ConflictsWithValidator{} + _ validator.Float64 = ConflictsWithValidator{} + _ validator.Int64 = ConflictsWithValidator{} + _ validator.List = ConflictsWithValidator{} + _ validator.Map = ConflictsWithValidator{} + _ validator.Number = ConflictsWithValidator{} + _ validator.Object = ConflictsWithValidator{} + _ validator.Set = ConflictsWithValidator{} + _ validator.String = ConflictsWithValidator{} +) + +// ConflictsWithValidator is the underlying struct implementing ConflictsWith. +type ConflictsWithValidator struct { + PathExpressions path.Expressions +} + +type ConflictsWithValidatorRequest struct { + Config tfsdk.Config + ConfigValue attr.Value + Path path.Path + PathExpression path.Expression +} + +type ConflictsWithValidatorResponse struct { + Diagnostics diag.Diagnostics +} + +func (av ConflictsWithValidator) Description(ctx context.Context) string { + return av.MarkdownDescription(ctx) +} + +func (av ConflictsWithValidator) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("Ensure that if an attribute is set, these are not set: %q", av.PathExpressions) +} + +func (av ConflictsWithValidator) Validate(ctx context.Context, req ConflictsWithValidatorRequest, res *ConflictsWithValidatorResponse) { + // If attribute configuration is null, it cannot conflict with others + if req.ConfigValue.IsNull() { + return + } + + expressions := req.PathExpression.MergeExpressions(av.PathExpressions...) + + for _, expression := range expressions { + matchedPaths, diags := req.Config.PathMatches(ctx, expression) + + res.Diagnostics.Append(diags...) + + // Collect all errors + if diags.HasError() { + continue + } + + for _, mp := range matchedPaths { + // If the user specifies the same attribute this validator is applied to, + // also as part of the input, skip it + if mp.Equal(req.Path) { + continue + } + + var mpVal attr.Value + diags := req.Config.GetAttribute(ctx, mp, &mpVal) + res.Diagnostics.Append(diags...) + + // Collect all errors + if diags.HasError() { + continue + } + + // Delay validation until all involved attribute have a known value + if mpVal.IsUnknown() { + return + } + + if !mpVal.IsNull() { + res.Diagnostics.Append(validatordiag.InvalidAttributeCombinationDiagnostic( + req.Path, + fmt.Sprintf("Attribute %q cannot be specified when %q is specified", mp, req.Path), + )) + } + } + } +} + +func (av ConflictsWithValidator) ValidateBool(ctx context.Context, req validator.BoolRequest, resp *validator.BoolResponse) { + validateReq := ConflictsWithValidatorRequest{ + Config: req.Config, + ConfigValue: req.ConfigValue, + Path: req.Path, + PathExpression: req.PathExpression, + } + validateResp := &ConflictsWithValidatorResponse{} + + av.Validate(ctx, validateReq, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) +} + +func (av ConflictsWithValidator) ValidateFloat64(ctx context.Context, req validator.Float64Request, resp *validator.Float64Response) { + validateReq := ConflictsWithValidatorRequest{ + Config: req.Config, + ConfigValue: req.ConfigValue, + Path: req.Path, + PathExpression: req.PathExpression, + } + validateResp := &ConflictsWithValidatorResponse{} + + av.Validate(ctx, validateReq, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) +} + +func (av ConflictsWithValidator) ValidateInt64(ctx context.Context, req validator.Int64Request, resp *validator.Int64Response) { + validateReq := ConflictsWithValidatorRequest{ + Config: req.Config, + ConfigValue: req.ConfigValue, + Path: req.Path, + PathExpression: req.PathExpression, + } + validateResp := &ConflictsWithValidatorResponse{} + + av.Validate(ctx, validateReq, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) +} + +func (av ConflictsWithValidator) ValidateList(ctx context.Context, req validator.ListRequest, resp *validator.ListResponse) { + validateReq := ConflictsWithValidatorRequest{ + Config: req.Config, + ConfigValue: req.ConfigValue, + Path: req.Path, + PathExpression: req.PathExpression, + } + validateResp := &ConflictsWithValidatorResponse{} + + av.Validate(ctx, validateReq, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) +} + +func (av ConflictsWithValidator) ValidateMap(ctx context.Context, req validator.MapRequest, resp *validator.MapResponse) { + validateReq := ConflictsWithValidatorRequest{ + Config: req.Config, + ConfigValue: req.ConfigValue, + Path: req.Path, + PathExpression: req.PathExpression, + } + validateResp := &ConflictsWithValidatorResponse{} + + av.Validate(ctx, validateReq, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) +} + +func (av ConflictsWithValidator) ValidateNumber(ctx context.Context, req validator.NumberRequest, resp *validator.NumberResponse) { + validateReq := ConflictsWithValidatorRequest{ + Config: req.Config, + ConfigValue: req.ConfigValue, + Path: req.Path, + PathExpression: req.PathExpression, + } + validateResp := &ConflictsWithValidatorResponse{} + + av.Validate(ctx, validateReq, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) +} + +func (av ConflictsWithValidator) ValidateObject(ctx context.Context, req validator.ObjectRequest, resp *validator.ObjectResponse) { + validateReq := ConflictsWithValidatorRequest{ + Config: req.Config, + ConfigValue: req.ConfigValue, + Path: req.Path, + PathExpression: req.PathExpression, + } + validateResp := &ConflictsWithValidatorResponse{} + + av.Validate(ctx, validateReq, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) +} + +func (av ConflictsWithValidator) ValidateSet(ctx context.Context, req validator.SetRequest, resp *validator.SetResponse) { + validateReq := ConflictsWithValidatorRequest{ + Config: req.Config, + ConfigValue: req.ConfigValue, + Path: req.Path, + PathExpression: req.PathExpression, + } + validateResp := &ConflictsWithValidatorResponse{} + + av.Validate(ctx, validateReq, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) +} + +func (av ConflictsWithValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { + validateReq := ConflictsWithValidatorRequest{ + Config: req.Config, + ConfigValue: req.ConfigValue, + Path: req.Path, + PathExpression: req.PathExpression, + } + validateResp := &ConflictsWithValidatorResponse{} + + av.Validate(ctx, validateReq, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) +} diff --git a/schemavalidator/conflicts_with_test.go b/internal/schemavalidator/conflicts_with_test.go similarity index 59% rename from schemavalidator/conflicts_with_test.go rename to internal/schemavalidator/conflicts_with_test.go index c648563..164aacd 100644 --- a/schemavalidator/conflicts_with_test.go +++ b/internal/schemavalidator/conflicts_with_test.go @@ -5,40 +5,35 @@ import ( "testing" "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/tfsdk" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" - "github.com/hashicorp/terraform-plugin-framework-validators/schemavalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" ) -func TestConflictsWithValidator(t *testing.T) { +func TestConflictsWithValidatorValidate(t *testing.T) { t.Parallel() type testCase struct { - req tfsdk.ValidateAttributeRequest + req schemavalidator.ConflictsWithValidatorRequest in path.Expressions expErrors int } testCases := map[string]testCase{ "base": { - req: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.StringValue("bar value"), - AttributePath: path.Root("bar"), - AttributePathExpression: path.MatchRoot("bar"), + req: schemavalidator.ConflictsWithValidatorRequest{ + ConfigValue: types.StringValue("bar value"), + Path: path.Root("bar"), + PathExpression: path.MatchRoot("bar"), Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "foo": { - Type: types.Int64Type, - }, - "bar": { - Type: types.StringType, - }, - "baz": { - Type: types.Int64Type, - }, + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "foo": schema.Int64Attribute{}, + "bar": schema.StringAttribute{}, + "baz": schema.Int64Attribute{}, }, }, Raw: tftypes.NewValue(tftypes.Object{ @@ -61,19 +56,15 @@ func TestConflictsWithValidator(t *testing.T) { expErrors: 2, }, "conflicting-is-nil": { - req: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.StringValue("bar value"), - AttributePath: path.Root("bar"), - AttributePathExpression: path.MatchRoot("bar"), + req: schemavalidator.ConflictsWithValidatorRequest{ + ConfigValue: types.StringValue("bar value"), + Path: path.Root("bar"), + PathExpression: path.MatchRoot("bar"), Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "foo": { - Type: types.Int64Type, - }, - "bar": { - Type: types.StringType, - }, + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "foo": schema.Int64Attribute{}, + "bar": schema.StringAttribute{}, }, }, Raw: tftypes.NewValue(tftypes.Object{ @@ -92,19 +83,15 @@ func TestConflictsWithValidator(t *testing.T) { }, }, "conflicting-is-unknown": { - req: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.StringValue("bar value"), - AttributePath: path.Root("bar"), - AttributePathExpression: path.MatchRoot("bar"), + req: schemavalidator.ConflictsWithValidatorRequest{ + ConfigValue: types.StringValue("bar value"), + Path: path.Root("bar"), + PathExpression: path.MatchRoot("bar"), Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "foo": { - Type: types.Int64Type, - }, - "bar": { - Type: types.StringType, - }, + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "foo": schema.Int64Attribute{}, + "bar": schema.StringAttribute{}, }, }, Raw: tftypes.NewValue(tftypes.Object{ @@ -123,19 +110,15 @@ func TestConflictsWithValidator(t *testing.T) { }, }, "self-is-null": { - req: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.StringNull(), - AttributePath: path.Root("bar"), - AttributePathExpression: path.MatchRoot("bar"), + req: schemavalidator.ConflictsWithValidatorRequest{ + ConfigValue: types.StringNull(), + Path: path.Root("bar"), + PathExpression: path.MatchRoot("bar"), Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "foo": { - Type: types.Int64Type, - }, - "bar": { - Type: types.StringType, - }, + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "foo": schema.Int64Attribute{}, + "bar": schema.StringAttribute{}, }, }, Raw: tftypes.NewValue(tftypes.Object{ @@ -154,22 +137,16 @@ func TestConflictsWithValidator(t *testing.T) { }, }, "error_allow-duplicate-input": { - req: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.StringValue("bar value"), - AttributePath: path.Root("bar"), - AttributePathExpression: path.MatchRoot("bar"), + req: schemavalidator.ConflictsWithValidatorRequest{ + ConfigValue: types.StringValue("bar value"), + Path: path.Root("bar"), + PathExpression: path.MatchRoot("bar"), Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "foo": { - Type: types.Int64Type, - }, - "bar": { - Type: types.StringType, - }, - "baz": { - Type: types.Int64Type, - }, + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "foo": schema.Int64Attribute{}, + "bar": schema.StringAttribute{}, + "baz": schema.Int64Attribute{}, }, }, Raw: tftypes.NewValue(tftypes.Object{ @@ -193,22 +170,16 @@ func TestConflictsWithValidator(t *testing.T) { expErrors: 2, }, "error_unknowns": { - req: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.StringValue("bar value"), - AttributePath: path.Root("bar"), - AttributePathExpression: path.MatchRoot("bar"), + req: schemavalidator.ConflictsWithValidatorRequest{ + ConfigValue: types.StringValue("bar value"), + Path: path.Root("bar"), + PathExpression: path.MatchRoot("bar"), Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "foo": { - Type: types.Int64Type, - }, - "bar": { - Type: types.StringType, - }, - "baz": { - Type: types.Int64Type, - }, + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "foo": schema.Int64Attribute{}, + "bar": schema.StringAttribute{}, + "baz": schema.Int64Attribute{}, }, }, Raw: tftypes.NewValue(tftypes.Object{ @@ -231,22 +202,16 @@ func TestConflictsWithValidator(t *testing.T) { //expErrors: 2, }, "matches-no-attribute-in-schema": { - req: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.StringValue("bar value"), - AttributePath: path.Root("bar"), - AttributePathExpression: path.MatchRoot("bar"), + req: schemavalidator.ConflictsWithValidatorRequest{ + ConfigValue: types.StringValue("bar value"), + Path: path.Root("bar"), + PathExpression: path.MatchRoot("bar"), Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "foo": { - Type: types.Int64Type, - }, - "bar": { - Type: types.StringType, - }, - "baz": { - Type: types.Int64Type, - }, + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "foo": schema.Int64Attribute{}, + "bar": schema.StringAttribute{}, + "baz": schema.Int64Attribute{}, }, }, Raw: tftypes.NewValue(tftypes.Object{ @@ -272,9 +237,11 @@ func TestConflictsWithValidator(t *testing.T) { for name, test := range testCases { t.Run(name, func(t *testing.T) { - res := tfsdk.ValidateAttributeResponse{} + res := &schemavalidator.ConflictsWithValidatorResponse{} - schemavalidator.ConflictsWith(test.in...).Validate(context.TODO(), test.req, &res) + schemavalidator.ConflictsWithValidator{ + PathExpressions: test.in, + }.Validate(context.TODO(), test.req, res) if test.expErrors > 0 && !res.Diagnostics.HasError() { t.Fatal("expected error(s), got none") diff --git a/schemavalidator/doc.go b/internal/schemavalidator/doc.go similarity index 100% rename from schemavalidator/doc.go rename to internal/schemavalidator/doc.go diff --git a/internal/schemavalidator/exactly_one_of.go b/internal/schemavalidator/exactly_one_of.go new file mode 100644 index 0000000..38d1f02 --- /dev/null +++ b/internal/schemavalidator/exactly_one_of.go @@ -0,0 +1,245 @@ +package schemavalidator + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" +) + +// This type of validator must satisfy all types. +var ( + _ validator.Bool = ExactlyOneOfValidator{} + _ validator.Float64 = ExactlyOneOfValidator{} + _ validator.Int64 = ExactlyOneOfValidator{} + _ validator.List = ExactlyOneOfValidator{} + _ validator.Map = ExactlyOneOfValidator{} + _ validator.Number = ExactlyOneOfValidator{} + _ validator.Object = ExactlyOneOfValidator{} + _ validator.Set = ExactlyOneOfValidator{} + _ validator.String = ExactlyOneOfValidator{} +) + +// ExactlyOneOfValidator is the underlying struct implementing ExactlyOneOf. +type ExactlyOneOfValidator struct { + PathExpressions path.Expressions +} + +type ExactlyOneOfValidatorRequest struct { + Config tfsdk.Config + ConfigValue attr.Value + Path path.Path + PathExpression path.Expression +} + +type ExactlyOneOfValidatorResponse struct { + Diagnostics diag.Diagnostics +} + +func (av ExactlyOneOfValidator) Description(ctx context.Context) string { + return av.MarkdownDescription(ctx) +} + +func (av ExactlyOneOfValidator) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("Ensure that one and only one attribute from this collection is set: %q", av.PathExpressions) +} + +func (av ExactlyOneOfValidator) Validate(ctx context.Context, req ExactlyOneOfValidatorRequest, res *ExactlyOneOfValidatorResponse) { + count := 0 + expressions := req.PathExpression.MergeExpressions(av.PathExpressions...) + + // If current attribute is unknown, delay validation + if req.ConfigValue.IsUnknown() { + return + } + + // Now that we know the current attribute is known, check whether it is + // null to determine if it should contribute to the count. Later logic + // will remove a duplicate matching path, should it be included in the + // given expressions. + if !req.ConfigValue.IsNull() { + count++ + } + + for _, expression := range expressions { + matchedPaths, diags := req.Config.PathMatches(ctx, expression) + + res.Diagnostics.Append(diags...) + + // Collect all errors + if diags.HasError() { + continue + } + + for _, mp := range matchedPaths { + // If the user specifies the same attribute this validator is applied to, + // also as part of the input, skip it + if mp.Equal(req.Path) { + continue + } + + var mpVal attr.Value + diags := req.Config.GetAttribute(ctx, mp, &mpVal) + res.Diagnostics.Append(diags...) + + // Collect all errors + if diags.HasError() { + continue + } + + // Delay validation until all involved attribute have a known value + if mpVal.IsUnknown() { + return + } + + if !mpVal.IsNull() { + count++ + } + } + } + + if count == 0 { + res.Diagnostics.Append(validatordiag.InvalidAttributeCombinationDiagnostic( + req.Path, + fmt.Sprintf("No attribute specified when one (and only one) of %s is required", expressions), + )) + } + + if count > 1 { + res.Diagnostics.Append(validatordiag.InvalidAttributeCombinationDiagnostic( + req.Path, + fmt.Sprintf("%d attributes specified when one (and only one) of %s is required", count, expressions), + )) + } +} + +func (av ExactlyOneOfValidator) ValidateBool(ctx context.Context, req validator.BoolRequest, resp *validator.BoolResponse) { + validateReq := ExactlyOneOfValidatorRequest{ + Config: req.Config, + ConfigValue: req.ConfigValue, + Path: req.Path, + PathExpression: req.PathExpression, + } + validateResp := &ExactlyOneOfValidatorResponse{} + + av.Validate(ctx, validateReq, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) +} + +func (av ExactlyOneOfValidator) ValidateFloat64(ctx context.Context, req validator.Float64Request, resp *validator.Float64Response) { + validateReq := ExactlyOneOfValidatorRequest{ + Config: req.Config, + ConfigValue: req.ConfigValue, + Path: req.Path, + PathExpression: req.PathExpression, + } + validateResp := &ExactlyOneOfValidatorResponse{} + + av.Validate(ctx, validateReq, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) +} + +func (av ExactlyOneOfValidator) ValidateInt64(ctx context.Context, req validator.Int64Request, resp *validator.Int64Response) { + validateReq := ExactlyOneOfValidatorRequest{ + Config: req.Config, + ConfigValue: req.ConfigValue, + Path: req.Path, + PathExpression: req.PathExpression, + } + validateResp := &ExactlyOneOfValidatorResponse{} + + av.Validate(ctx, validateReq, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) +} + +func (av ExactlyOneOfValidator) ValidateList(ctx context.Context, req validator.ListRequest, resp *validator.ListResponse) { + validateReq := ExactlyOneOfValidatorRequest{ + Config: req.Config, + ConfigValue: req.ConfigValue, + Path: req.Path, + PathExpression: req.PathExpression, + } + validateResp := &ExactlyOneOfValidatorResponse{} + + av.Validate(ctx, validateReq, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) +} + +func (av ExactlyOneOfValidator) ValidateMap(ctx context.Context, req validator.MapRequest, resp *validator.MapResponse) { + validateReq := ExactlyOneOfValidatorRequest{ + Config: req.Config, + ConfigValue: req.ConfigValue, + Path: req.Path, + PathExpression: req.PathExpression, + } + validateResp := &ExactlyOneOfValidatorResponse{} + + av.Validate(ctx, validateReq, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) +} + +func (av ExactlyOneOfValidator) ValidateNumber(ctx context.Context, req validator.NumberRequest, resp *validator.NumberResponse) { + validateReq := ExactlyOneOfValidatorRequest{ + Config: req.Config, + ConfigValue: req.ConfigValue, + Path: req.Path, + PathExpression: req.PathExpression, + } + validateResp := &ExactlyOneOfValidatorResponse{} + + av.Validate(ctx, validateReq, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) +} + +func (av ExactlyOneOfValidator) ValidateObject(ctx context.Context, req validator.ObjectRequest, resp *validator.ObjectResponse) { + validateReq := ExactlyOneOfValidatorRequest{ + Config: req.Config, + ConfigValue: req.ConfigValue, + Path: req.Path, + PathExpression: req.PathExpression, + } + validateResp := &ExactlyOneOfValidatorResponse{} + + av.Validate(ctx, validateReq, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) +} + +func (av ExactlyOneOfValidator) ValidateSet(ctx context.Context, req validator.SetRequest, resp *validator.SetResponse) { + validateReq := ExactlyOneOfValidatorRequest{ + Config: req.Config, + ConfigValue: req.ConfigValue, + Path: req.Path, + PathExpression: req.PathExpression, + } + validateResp := &ExactlyOneOfValidatorResponse{} + + av.Validate(ctx, validateReq, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) +} + +func (av ExactlyOneOfValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { + validateReq := ExactlyOneOfValidatorRequest{ + Config: req.Config, + ConfigValue: req.ConfigValue, + Path: req.Path, + PathExpression: req.PathExpression, + } + validateResp := &ExactlyOneOfValidatorResponse{} + + av.Validate(ctx, validateReq, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) +} diff --git a/schemavalidator/exactly_one_of_test.go b/internal/schemavalidator/exactly_one_of_test.go similarity index 60% rename from schemavalidator/exactly_one_of_test.go rename to internal/schemavalidator/exactly_one_of_test.go index 41d5137..80ed682 100644 --- a/schemavalidator/exactly_one_of_test.go +++ b/internal/schemavalidator/exactly_one_of_test.go @@ -5,37 +5,34 @@ import ( "testing" "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/tfsdk" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" - "github.com/hashicorp/terraform-plugin-framework-validators/schemavalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" ) func TestExactlyOneOfValidator(t *testing.T) { t.Parallel() type testCase struct { - req tfsdk.ValidateAttributeRequest + req schemavalidator.ExactlyOneOfValidatorRequest in path.Expressions expErrors int } testCases := map[string]testCase{ "base": { - req: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.StringValue("bar value"), - AttributePath: path.Root("bar"), - AttributePathExpression: path.MatchRoot("bar"), + req: schemavalidator.ExactlyOneOfValidatorRequest{ + ConfigValue: types.StringValue("bar value"), + Path: path.Root("bar"), + PathExpression: path.MatchRoot("bar"), Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "foo": { - Type: types.Int64Type, - }, - "bar": { - Type: types.StringType, - }, + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "foo": schema.Int64Attribute{}, + "bar": schema.StringAttribute{}, }, }, Raw: tftypes.NewValue(tftypes.Object{ @@ -55,19 +52,15 @@ func TestExactlyOneOfValidator(t *testing.T) { expErrors: 1, }, "self-is-null": { - req: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.StringNull(), - AttributePath: path.Root("bar"), - AttributePathExpression: path.MatchRoot("bar"), + req: schemavalidator.ExactlyOneOfValidatorRequest{ + ConfigValue: types.StringNull(), + Path: path.Root("bar"), + PathExpression: path.MatchRoot("bar"), Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "foo": { - Type: types.Int64Type, - }, - "bar": { - Type: types.StringType, - }, + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "foo": schema.Int64Attribute{}, + "bar": schema.StringAttribute{}, }, }, Raw: tftypes.NewValue(tftypes.Object{ @@ -86,22 +79,16 @@ func TestExactlyOneOfValidator(t *testing.T) { }, }, "error_too-many": { - req: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.StringValue("bar value"), - AttributePath: path.Root("bar"), - AttributePathExpression: path.MatchRoot("bar"), + req: schemavalidator.ExactlyOneOfValidatorRequest{ + ConfigValue: types.StringValue("bar value"), + Path: path.Root("bar"), + PathExpression: path.MatchRoot("bar"), Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "foo": { - Type: types.Int64Type, - }, - "bar": { - Type: types.StringType, - }, - "baz": { - Type: types.Float64Type, - }, + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "foo": schema.Int64Attribute{}, + "bar": schema.StringAttribute{}, + "baz": schema.Float64Attribute{}, }, }, Raw: tftypes.NewValue(tftypes.Object{ @@ -124,22 +111,16 @@ func TestExactlyOneOfValidator(t *testing.T) { expErrors: 1, }, "error_too-few": { - req: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.StringNull(), - AttributePath: path.Root("bar"), - AttributePathExpression: path.MatchRoot("bar"), + req: schemavalidator.ExactlyOneOfValidatorRequest{ + ConfigValue: types.StringNull(), + Path: path.Root("bar"), + PathExpression: path.MatchRoot("bar"), Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "foo": { - Type: types.Int64Type, - }, - "bar": { - Type: types.StringType, - }, - "baz": { - Type: types.Int64Type, - }, + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "foo": schema.Int64Attribute{}, + "bar": schema.StringAttribute{}, + "baz": schema.Int64Attribute{}, }, }, Raw: tftypes.NewValue(tftypes.Object{ @@ -162,22 +143,16 @@ func TestExactlyOneOfValidator(t *testing.T) { expErrors: 1, }, "allow-duplicate-input": { - req: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.StringValue("bar value"), - AttributePath: path.Root("bar"), - AttributePathExpression: path.MatchRoot("bar"), + req: schemavalidator.ExactlyOneOfValidatorRequest{ + ConfigValue: types.StringValue("bar value"), + Path: path.Root("bar"), + PathExpression: path.MatchRoot("bar"), Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "foo": { - Type: types.Int64Type, - }, - "bar": { - Type: types.StringType, - }, - "baz": { - Type: types.Int64Type, - }, + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "foo": schema.Int64Attribute{}, + "bar": schema.StringAttribute{}, + "baz": schema.Int64Attribute{}, }, }, Raw: tftypes.NewValue(tftypes.Object{ @@ -200,22 +175,16 @@ func TestExactlyOneOfValidator(t *testing.T) { }, }, "other-attributes-are-unknown": { - req: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.StringValue("bar value"), - AttributePath: path.Root("bar"), - AttributePathExpression: path.MatchRoot("bar"), + req: schemavalidator.ExactlyOneOfValidatorRequest{ + ConfigValue: types.StringValue("bar value"), + Path: path.Root("bar"), + PathExpression: path.MatchRoot("bar"), Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "foo": { - Type: types.Int64Type, - }, - "bar": { - Type: types.StringType, - }, - "baz": { - Type: types.Int64Type, - }, + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "foo": schema.Int64Attribute{}, + "bar": schema.StringAttribute{}, + "baz": schema.Int64Attribute{}, }, }, Raw: tftypes.NewValue(tftypes.Object{ @@ -237,19 +206,15 @@ func TestExactlyOneOfValidator(t *testing.T) { }, }, "matches-no-attribute-in-schema": { - req: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.StringValue("bar value"), - AttributePath: path.Root("bar"), - AttributePathExpression: path.MatchRoot("bar"), + req: schemavalidator.ExactlyOneOfValidatorRequest{ + ConfigValue: types.StringValue("bar value"), + Path: path.Root("bar"), + PathExpression: path.MatchRoot("bar"), Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "foo": { - Type: types.Int64Type, - }, - "bar": { - Type: types.StringType, - }, + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "foo": schema.Int64Attribute{}, + "bar": schema.StringAttribute{}, }, }, Raw: tftypes.NewValue(tftypes.Object{ @@ -273,9 +238,11 @@ func TestExactlyOneOfValidator(t *testing.T) { for name, test := range testCases { t.Run(name, func(t *testing.T) { - res := tfsdk.ValidateAttributeResponse{} + res := &schemavalidator.ExactlyOneOfValidatorResponse{} - schemavalidator.ExactlyOneOf(test.in...).Validate(context.TODO(), test.req, &res) + schemavalidator.ExactlyOneOfValidator{ + PathExpressions: test.in, + }.Validate(context.TODO(), test.req, res) if test.expErrors > 0 && !res.Diagnostics.HasError() { t.Fatal("expected error(s), got none") diff --git a/internal/testvalidator/object.go b/internal/testvalidator/object.go new file mode 100644 index 0000000..86819aa --- /dev/null +++ b/internal/testvalidator/object.go @@ -0,0 +1,26 @@ +package testvalidator + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +var _ validator.Object = ObjectValidator{} + +type ObjectValidator struct { + Diagnostics diag.Diagnostics +} + +func (v ObjectValidator) Description(ctx context.Context) string { + return "returns given Diagnostics" +} + +func (v ObjectValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +func (v ObjectValidator) ValidateObject(ctx context.Context, req validator.ObjectRequest, resp *validator.ObjectResponse) { + resp.Diagnostics = v.Diagnostics +} diff --git a/internal/testvalidator/warning.go b/internal/testvalidator/warning.go new file mode 100644 index 0000000..221c759 --- /dev/null +++ b/internal/testvalidator/warning.go @@ -0,0 +1,140 @@ +package testvalidator + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// WarningBool returns a validator which returns a warning diagnostic. +func WarningBool(summary string, detail string) validator.Bool { + return WarningValidator{ + Summary: summary, + Detail: detail, + } +} + +// WarningFloat64 returns a validator which returns a warning diagnostic. +func WarningFloat64(summary string, detail string) validator.Float64 { + return WarningValidator{ + Summary: summary, + Detail: detail, + } +} + +// WarningInt64 returns a validator which returns a warning diagnostic. +func WarningInt64(summary string, detail string) validator.Int64 { + return WarningValidator{ + Summary: summary, + Detail: detail, + } +} + +// WarningList returns a validator which returns a warning diagnostic. +func WarningList(summary string, detail string) validator.List { + return WarningValidator{ + Summary: summary, + Detail: detail, + } +} + +// WarningMap returns a validator which returns a warning diagnostic. +func WarningMap(summary string, detail string) validator.Map { + return WarningValidator{ + Summary: summary, + Detail: detail, + } +} + +// WarningNumber returns a validator which returns a warning diagnostic. +func WarningNumber(summary string, detail string) validator.Number { + return WarningValidator{ + Summary: summary, + Detail: detail, + } +} + +// WarningObject returns a validator which returns a warning diagnostic. +func WarningObject(summary string, detail string) validator.Object { + 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{ + Summary: summary, + Detail: detail, + } +} + +// WarningString returns a validator which returns a warning diagnostic. +func WarningString(summary string, detail string) validator.String { + return WarningValidator{ + Summary: summary, + Detail: detail, + } +} + +var ( + _ validator.Bool = WarningValidator{} + _ validator.Float64 = WarningValidator{} + _ validator.Int64 = WarningValidator{} + _ validator.List = WarningValidator{} + _ validator.Map = WarningValidator{} + _ validator.Number = WarningValidator{} + _ validator.Object = WarningValidator{} + _ validator.Set = WarningValidator{} + _ validator.String = WarningValidator{} +) + +type WarningValidator struct { + Summary string + Detail string +} + +func (v WarningValidator) Description(_ context.Context) string { + return "always returns a warning diagnostic" +} + +func (v WarningValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +func (v WarningValidator) ValidateBool(ctx context.Context, request validator.BoolRequest, response *validator.BoolResponse) { + response.Diagnostics.AddWarning(v.Summary, v.Detail) +} + +func (v WarningValidator) ValidateFloat64(ctx context.Context, request validator.Float64Request, response *validator.Float64Response) { + response.Diagnostics.AddWarning(v.Summary, v.Detail) +} + +func (v WarningValidator) ValidateInt64(ctx context.Context, request validator.Int64Request, response *validator.Int64Response) { + response.Diagnostics.AddWarning(v.Summary, v.Detail) +} + +func (v WarningValidator) ValidateList(ctx context.Context, request validator.ListRequest, response *validator.ListResponse) { + response.Diagnostics.AddWarning(v.Summary, v.Detail) +} + +func (v WarningValidator) ValidateMap(ctx context.Context, request validator.MapRequest, response *validator.MapResponse) { + response.Diagnostics.AddWarning(v.Summary, v.Detail) +} + +func (v WarningValidator) ValidateNumber(ctx context.Context, request validator.NumberRequest, response *validator.NumberResponse) { + response.Diagnostics.AddWarning(v.Summary, v.Detail) +} + +func (v WarningValidator) ValidateObject(ctx context.Context, request validator.ObjectRequest, response *validator.ObjectResponse) { + 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) +} + +func (v WarningValidator) ValidateString(ctx context.Context, request validator.StringRequest, response *validator.StringResponse) { + response.Diagnostics.AddWarning(v.Summary, v.Detail) +} diff --git a/listvalidator/all.go b/listvalidator/all.go new file mode 100644 index 0000000..7f6f1d5 --- /dev/null +++ b/listvalidator/all.go @@ -0,0 +1,54 @@ +package listvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// All returns a validator which ensures that any configured attribute value +// 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 ...validator.List) validator.List { + return allValidator{ + validators: validators, + } +} + +var _ validator.List = allValidator{} + +// allValidator implements the validator. +type allValidator struct { + validators []validator.List +} + +// 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) +} + +// ValidateList performs the validation. +func (v allValidator) ValidateList(ctx context.Context, req validator.ListRequest, resp *validator.ListResponse) { + for _, subValidator := range v.validators { + validateResp := &validator.ListResponse{} + + subValidator.ValidateList(ctx, req, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) + } +} diff --git a/listvalidator/all_example_test.go b/listvalidator/all_example_test.go new file mode 100644 index 0000000..d0e66c3 --- /dev/null +++ b/listvalidator/all_example_test.go @@ -0,0 +1,32 @@ +package listvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ExampleAll() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.ListAttribute{ + ElementType: types.StringType, + Required: true, + Validators: []validator.List{ + // Validate this List value must either be: + // - More than 5 elements + // - At least 2 elements, but not more than 3 elements + listvalidator.Any( + listvalidator.SizeAtLeast(5), + listvalidator.All( + listvalidator.SizeAtLeast(2), + listvalidator.SizeAtMost(3), + ), + ), + }, + }, + }, + } +} diff --git a/listvalidator/all_test.go b/listvalidator/all_test.go new file mode 100644 index 0000000..f9a9916 --- /dev/null +++ b/listvalidator/all_test.go @@ -0,0 +1,83 @@ +package listvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" +) + +func TestAllValidatorValidateList(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.List + validators []validator.List + expected diag.Diagnostics + } + tests := map[string]testCase{ + "invalid": { + val: types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + validators: []validator.List{ + listvalidator.SizeAtLeast(3), + listvalidator.SizeAtLeast(5), + }, + expected: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid Attribute Value", + "Attribute test list must contain at least 3 elements, got: 2", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid Attribute Value", + "Attribute test list must contain at least 5 elements, got: 2", + ), + }, + }, + "valid": { + val: types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + validators: []validator.List{ + listvalidator.SizeAtLeast(0), + listvalidator.SizeAtLeast(1), + }, + expected: nil, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + request := validator.ListRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + } + response := validator.ListResponse{} + listvalidator.All(test.validators...).ValidateList(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, test.expected); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/listvalidator/also_requires.go b/listvalidator/also_requires.go new file mode 100644 index 0000000..4af18a2 --- /dev/null +++ b/listvalidator/also_requires.go @@ -0,0 +1,23 @@ +package listvalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// AlsoRequires checks that a set of path.Expression has a non-null value, +// if the current attribute or block also has a non-null value. +// +// This implements the validation logic declaratively within the schema. +// Refer to [datasourcevalidator.RequiredTogether], +// [providervalidator.RequiredTogether], or [resourcevalidator.RequiredTogether] +// for declaring this type of validation outside the schema definition. +// +// Relative path.Expression will be resolved using the attribute or block +// being validated. +func AlsoRequires(expressions ...path.Expression) validator.List { + return schemavalidator.AlsoRequiresValidator{ + PathExpressions: expressions, + } +} diff --git a/listvalidator/also_requires_example_test.go b/listvalidator/also_requires_example_test.go new file mode 100644 index 0000000..d92b3d0 --- /dev/null +++ b/listvalidator/also_requires_example_test.go @@ -0,0 +1,30 @@ +package listvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ExampleAlsoRequires() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.ListAttribute{ + ElementType: types.StringType, + Optional: true, + Validators: []validator.List{ + // Validate this attribute must be configured with other_attr. + listvalidator.AlsoRequires(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.StringAttribute{ + Optional: true, + }, + }, + } +} diff --git a/listvalidator/any.go b/listvalidator/any.go new file mode 100644 index 0000000..e3bf6c8 --- /dev/null +++ b/listvalidator/any.go @@ -0,0 +1,62 @@ +package listvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// 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 ...validator.List) validator.List { + return anyValidator{ + validators: validators, + } +} + +var _ validator.List = anyValidator{} + +// anyValidator implements the validator. +type anyValidator struct { + validators []validator.List +} + +// 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) +} + +// ValidateList performs the validation. +func (v anyValidator) ValidateList(ctx context.Context, req validator.ListRequest, resp *validator.ListResponse) { + for _, subValidator := range v.validators { + validateResp := &validator.ListResponse{} + + subValidator.ValidateList(ctx, req, validateResp) + + if !validateResp.Diagnostics.HasError() { + resp.Diagnostics = validateResp.Diagnostics + + return + } + + resp.Diagnostics.Append(validateResp.Diagnostics...) + } +} diff --git a/listvalidator/any_example_test.go b/listvalidator/any_example_test.go new file mode 100644 index 0000000..c00fa6d --- /dev/null +++ b/listvalidator/any_example_test.go @@ -0,0 +1,27 @@ +package listvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleAny() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.ListAttribute{ + Required: true, + Validators: []validator.List{ + // Validate this List value must either be: + // - Between 1 and 2 elements + // - At least 4 elements + listvalidator.Any( + listvalidator.SizeBetween(1, 2), + listvalidator.SizeAtLeast(4), + ), + }, + }, + }, + } +} diff --git a/listvalidator/any_test.go b/listvalidator/any_test.go new file mode 100644 index 0000000..538db5e --- /dev/null +++ b/listvalidator/any_test.go @@ -0,0 +1,100 @@ +package listvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/internal/testvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" +) + +func TestAnyValidatorValidateList(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.List + validators []validator.List + expected diag.Diagnostics + } + tests := map[string]testCase{ + "invalid": { + val: types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + validators: []validator.List{ + listvalidator.SizeAtLeast(3), + listvalidator.SizeAtLeast(5), + }, + expected: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid Attribute Value", + "Attribute test list must contain at least 3 elements, got: 2", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid Attribute Value", + "Attribute test list must contain at least 5 elements, got: 2", + ), + }, + }, + "valid": { + val: types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + validators: []validator.List{ + listvalidator.SizeAtLeast(4), + listvalidator.SizeAtLeast(2), + }, + expected: diag.Diagnostics{}, + }, + "valid with warning": { + val: types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + validators: []validator.List{ + listvalidator.All(listvalidator.SizeAtLeast(5), testvalidator.WarningList("failing warning summary", "failing warning details")), + listvalidator.All(listvalidator.SizeAtLeast(2), testvalidator.WarningList("passing warning summary", "passing warning details")), + }, + expected: diag.Diagnostics{ + diag.NewWarningDiagnostic("passing warning summary", "passing warning details"), + }, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + request := validator.ListRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + } + response := validator.ListResponse{} + listvalidator.Any(test.validators...).ValidateList(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, test.expected); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/listvalidator/any_with_all_warnings.go b/listvalidator/any_with_all_warnings.go new file mode 100644 index 0000000..b31a1a3 --- /dev/null +++ b/listvalidator/any_with_all_warnings.go @@ -0,0 +1,64 @@ +package listvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// 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 ...validator.List) validator.List { + return anyWithAllWarningsValidator{ + validators: validators, + } +} + +var _ validator.List = anyWithAllWarningsValidator{} + +// anyWithAllWarningsValidator implements the validator. +type anyWithAllWarningsValidator struct { + validators []validator.List +} + +// 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) +} + +// ValidateList performs the validation. +func (v anyWithAllWarningsValidator) ValidateList(ctx context.Context, req validator.ListRequest, resp *validator.ListResponse) { + anyValid := false + + for _, subValidator := range v.validators { + validateResp := &validator.ListResponse{} + + subValidator.ValidateList(ctx, req, validateResp) + + if !validateResp.Diagnostics.HasError() { + anyValid = true + } + + resp.Diagnostics.Append(validateResp.Diagnostics...) + } + + if anyValid { + resp.Diagnostics = resp.Diagnostics.Warnings() + } +} diff --git a/listvalidator/any_with_all_warnings_example_test.go b/listvalidator/any_with_all_warnings_example_test.go new file mode 100644 index 0000000..4efaf89 --- /dev/null +++ b/listvalidator/any_with_all_warnings_example_test.go @@ -0,0 +1,27 @@ +package listvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleAnyWithAllWarnings() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.ListAttribute{ + Required: true, + Validators: []validator.List{ + // Validate this List value must either be: + // - Between 1 and 2 elements + // - At least 4 elements + listvalidator.AnyWithAllWarnings( + listvalidator.SizeBetween(1, 2), + listvalidator.SizeAtLeast(4), + ), + }, + }, + }, + } +} diff --git a/listvalidator/any_with_all_warnings_test.go b/listvalidator/any_with_all_warnings_test.go new file mode 100644 index 0000000..ae82f46 --- /dev/null +++ b/listvalidator/any_with_all_warnings_test.go @@ -0,0 +1,101 @@ +package listvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/internal/testvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" +) + +func TestAnyWithAllWarningsValidatorValidateList(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.List + validators []validator.List + expected diag.Diagnostics + } + tests := map[string]testCase{ + "invalid": { + val: types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + validators: []validator.List{ + listvalidator.SizeAtLeast(3), + listvalidator.SizeAtLeast(5), + }, + expected: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid Attribute Value", + "Attribute test list must contain at least 3 elements, got: 2", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid Attribute Value", + "Attribute test list must contain at least 5 elements, got: 2", + ), + }, + }, + "valid": { + val: types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + validators: []validator.List{ + listvalidator.SizeAtLeast(5), + listvalidator.SizeAtLeast(2), + }, + expected: diag.Diagnostics{}, + }, + "valid with warning": { + val: types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + validators: []validator.List{ + listvalidator.All(listvalidator.SizeAtLeast(5), testvalidator.WarningList("failing warning summary", "failing warning details")), + listvalidator.All(listvalidator.SizeAtLeast(2), testvalidator.WarningList("passing warning summary", "passing warning details")), + }, + expected: diag.Diagnostics{ + diag.NewWarningDiagnostic("failing warning summary", "failing warning details"), + diag.NewWarningDiagnostic("passing warning summary", "passing warning details"), + }, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + request := validator.ListRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + } + response := validator.ListResponse{} + listvalidator.AnyWithAllWarnings(test.validators...).ValidateList(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, test.expected); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/listvalidator/at_least_one_of.go b/listvalidator/at_least_one_of.go new file mode 100644 index 0000000..9450490 --- /dev/null +++ b/listvalidator/at_least_one_of.go @@ -0,0 +1,24 @@ +package listvalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// AtLeastOneOf checks that of a set of path.Expression, +// including the attribute or block this validator is applied to, +// at least one has a non-null value. +// +// This implements the validation logic declaratively within the tfsdk.Schema. +// Refer to [datasourcevalidator.AtLeastOneOf], +// [providervalidator.AtLeastOneOf], or [resourcevalidator.AtLeastOneOf] +// for declaring this type of validation outside the schema definition. +// +// Any relative path.Expression will be resolved using the attribute or block +// being validated. +func AtLeastOneOf(expressions ...path.Expression) validator.List { + return schemavalidator.AtLeastOneOfValidator{ + PathExpressions: expressions, + } +} diff --git a/listvalidator/at_least_one_of_example_test.go b/listvalidator/at_least_one_of_example_test.go new file mode 100644 index 0000000..69fddbd --- /dev/null +++ b/listvalidator/at_least_one_of_example_test.go @@ -0,0 +1,30 @@ +package listvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ExampleAtLeastOneOf() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.ListAttribute{ + ElementType: types.StringType, + Optional: true, + Validators: []validator.List{ + // Validate at least this attribute or other_attr should be configured. + listvalidator.AtLeastOneOf(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.StringAttribute{ + Optional: true, + }, + }, + } +} diff --git a/listvalidator/conflicts_with.go b/listvalidator/conflicts_with.go new file mode 100644 index 0000000..e8e5c51 --- /dev/null +++ b/listvalidator/conflicts_with.go @@ -0,0 +1,24 @@ +package listvalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// ConflictsWith checks that a set of path.Expression, +// including the attribute or block the validator is applied to, +// do not have a value simultaneously. +// +// This implements the validation logic declaratively within the schema. +// Refer to [datasourcevalidator.Conflicting], +// [providervalidator.Conflicting], or [resourcevalidator.Conflicting] +// for declaring this type of validation outside the schema definition. +// +// Relative path.Expression will be resolved using the attribute or block +// being validated. +func ConflictsWith(expressions ...path.Expression) validator.List { + return schemavalidator.ConflictsWithValidator{ + PathExpressions: expressions, + } +} diff --git a/listvalidator/conflicts_with_example_test.go b/listvalidator/conflicts_with_example_test.go new file mode 100644 index 0000000..86f3847 --- /dev/null +++ b/listvalidator/conflicts_with_example_test.go @@ -0,0 +1,30 @@ +package listvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ExampleConflictsWith() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.ListAttribute{ + ElementType: types.StringType, + Optional: true, + Validators: []validator.List{ + // Validate this attribute must not be configured with other_attr. + listvalidator.ConflictsWith(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.StringAttribute{ + Optional: true, + }, + }, + } +} diff --git a/listvalidator/exactly_one_of.go b/listvalidator/exactly_one_of.go new file mode 100644 index 0000000..3ca5e6a --- /dev/null +++ b/listvalidator/exactly_one_of.go @@ -0,0 +1,25 @@ +package listvalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// ExactlyOneOf checks that of a set of path.Expression, +// including the attribute or block the validator is applied to, +// one and only one attribute has a value. +// It will also cause a validation error if none are specified. +// +// This implements the validation logic declaratively within the schema. +// Refer to [datasourcevalidator.ExactlyOneOf], +// [providervalidator.ExactlyOneOf], or [resourcevalidator.ExactlyOneOf] +// for declaring this type of validation outside the schema definition. +// +// Relative path.Expression will be resolved using the attribute or block +// being validated. +func ExactlyOneOf(expressions ...path.Expression) validator.List { + return schemavalidator.ExactlyOneOfValidator{ + PathExpressions: expressions, + } +} diff --git a/listvalidator/exactly_one_of_example_test.go b/listvalidator/exactly_one_of_example_test.go new file mode 100644 index 0000000..a09aea5 --- /dev/null +++ b/listvalidator/exactly_one_of_example_test.go @@ -0,0 +1,30 @@ +package listvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ExampleExactlyOneOf() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.ListAttribute{ + ElementType: types.StringType, + Optional: true, + Validators: []validator.List{ + // Validate only this attribute or other_attr is configured. + listvalidator.ExactlyOneOf(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.StringAttribute{ + Optional: true, + }, + }, + } +} diff --git a/listvalidator/size_at_least.go b/listvalidator/size_at_least.go index 64ff8b0..5c7d521 100644 --- a/listvalidator/size_at_least.go +++ b/listvalidator/size_at_least.go @@ -5,10 +5,10 @@ import ( "fmt" "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" ) -var _ tfsdk.AttributeValidator = sizeAtLeastValidator{} +var _ validator.List = sizeAtLeastValidator{} // sizeAtLeastValidator validates that list contains at least min elements. type sizeAtLeastValidator struct { @@ -26,20 +26,19 @@ func (v sizeAtLeastValidator) MarkdownDescription(ctx context.Context) string { } // Validate performs the validation. -func (v sizeAtLeastValidator) Validate(ctx context.Context, req tfsdk.ValidateAttributeRequest, resp *tfsdk.ValidateAttributeResponse) { - elems, ok := validateList(ctx, req, resp) - if !ok { +func (v sizeAtLeastValidator) ValidateList(ctx context.Context, req validator.ListRequest, resp *validator.ListResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { return } + elems := req.ConfigValue.Elements() + if len(elems) < v.min { resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( - req.AttributePath, + req.Path, v.Description(ctx), fmt.Sprintf("%d", len(elems)), )) - - return } } @@ -50,7 +49,7 @@ func (v sizeAtLeastValidator) Validate(ctx context.Context, req tfsdk.ValidateAt // - Contains at least min elements. // // Null (unconfigured) and unknown (known after apply) values are skipped. -func SizeAtLeast(min int) tfsdk.AttributeValidator { +func SizeAtLeast(min int) validator.List { return sizeAtLeastValidator{ min: min, } diff --git a/listvalidator/size_at_least_example_test.go b/listvalidator/size_at_least_example_test.go index 5d732f2..5ebbed6 100644 --- a/listvalidator/size_at_least_example_test.go +++ b/listvalidator/size_at_least_example_test.go @@ -2,20 +2,19 @@ package listvalidator_test import ( "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" ) func ExampleSizeAtLeast() { - // Used within a GetSchema method of a DataSource, Provider, or Resource - _ = tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "example_attr": { - Required: true, - Type: types.ListType{ - ElemType: types.StringType, - }, - Validators: []tfsdk.AttributeValidator{ + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.ListAttribute{ + ElementType: types.StringType, + Required: true, + Validators: []validator.List{ // Validate this list must contain at least 2 elements. listvalidator.SizeAtLeast(2), }, diff --git a/listvalidator/size_at_least_test.go b/listvalidator/size_at_least_test.go index c874f66..e047354 100644 --- a/listvalidator/size_at_least_test.go +++ b/listvalidator/size_at_least_test.go @@ -6,7 +6,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" ) @@ -14,15 +14,11 @@ func TestSizeAtLeastValidator(t *testing.T) { t.Parallel() type testCase struct { - val attr.Value + val types.List min int expectError bool } tests := map[string]testCase{ - "not a List": { - val: types.BoolValue(true), - expectError: true, - }, "List unknown": { val: types.ListUnknown( types.StringType, @@ -69,13 +65,13 @@ func TestSizeAtLeastValidator(t *testing.T) { for name, test := range tests { name, test := name, test t.Run(name, func(t *testing.T) { - request := tfsdk.ValidateAttributeRequest{ - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - AttributeConfig: test.val, + request := validator.ListRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, } - response := tfsdk.ValidateAttributeResponse{} - SizeAtLeast(test.min).Validate(context.TODO(), request, &response) + response := validator.ListResponse{} + SizeAtLeast(test.min).ValidateList(context.TODO(), request, &response) if !response.Diagnostics.HasError() && test.expectError { t.Fatal("expected error, got no error") diff --git a/listvalidator/size_at_most.go b/listvalidator/size_at_most.go index e4c31b3..30df148 100644 --- a/listvalidator/size_at_most.go +++ b/listvalidator/size_at_most.go @@ -5,10 +5,10 @@ import ( "fmt" "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" ) -var _ tfsdk.AttributeValidator = sizeAtMostValidator{} +var _ validator.List = sizeAtMostValidator{} // sizeAtMostValidator validates that list contains at most max elements. type sizeAtMostValidator struct { @@ -26,20 +26,19 @@ func (v sizeAtMostValidator) MarkdownDescription(ctx context.Context) string { } // Validate performs the validation. -func (v sizeAtMostValidator) Validate(ctx context.Context, req tfsdk.ValidateAttributeRequest, resp *tfsdk.ValidateAttributeResponse) { - elems, ok := validateList(ctx, req, resp) - if !ok { +func (v sizeAtMostValidator) ValidateList(ctx context.Context, req validator.ListRequest, resp *validator.ListResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { return } + elems := req.ConfigValue.Elements() + if len(elems) > v.max { resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( - req.AttributePath, + req.Path, v.Description(ctx), fmt.Sprintf("%d", len(elems)), )) - - return } } @@ -50,7 +49,7 @@ func (v sizeAtMostValidator) Validate(ctx context.Context, req tfsdk.ValidateAtt // - Contains at most max elements. // // Null (unconfigured) and unknown (known after apply) values are skipped. -func SizeAtMost(max int) tfsdk.AttributeValidator { +func SizeAtMost(max int) validator.List { return sizeAtMostValidator{ max: max, } diff --git a/listvalidator/size_at_most_example_test.go b/listvalidator/size_at_most_example_test.go index ccb25ef..87ed149 100644 --- a/listvalidator/size_at_most_example_test.go +++ b/listvalidator/size_at_most_example_test.go @@ -2,20 +2,19 @@ package listvalidator_test import ( "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" ) func ExampleSizeAtMost() { - // Used within a GetSchema method of a DataSource, Provider, or Resource - _ = tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "example_attr": { - Required: true, - Type: types.ListType{ - ElemType: types.StringType, - }, - Validators: []tfsdk.AttributeValidator{ + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.ListAttribute{ + ElementType: types.StringType, + Required: true, + Validators: []validator.List{ // Validate this list must contain at most 2 elements. listvalidator.SizeAtMost(2), }, diff --git a/listvalidator/size_at_most_test.go b/listvalidator/size_at_most_test.go index 70c4c89..7223b03 100644 --- a/listvalidator/size_at_most_test.go +++ b/listvalidator/size_at_most_test.go @@ -6,7 +6,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" ) @@ -14,15 +14,11 @@ func TestSizeAtMostValidator(t *testing.T) { t.Parallel() type testCase struct { - val attr.Value + val types.List max int expectError bool } tests := map[string]testCase{ - "not a List": { - val: types.BoolValue(true), - expectError: true, - }, "List unknown": { val: types.ListUnknown( types.StringType, @@ -73,13 +69,13 @@ func TestSizeAtMostValidator(t *testing.T) { for name, test := range tests { name, test := name, test t.Run(name, func(t *testing.T) { - request := tfsdk.ValidateAttributeRequest{ - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - AttributeConfig: test.val, + request := validator.ListRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, } - response := tfsdk.ValidateAttributeResponse{} - SizeAtMost(test.max).Validate(context.TODO(), request, &response) + response := validator.ListResponse{} + SizeAtMost(test.max).ValidateList(context.TODO(), request, &response) if !response.Diagnostics.HasError() && test.expectError { t.Fatal("expected error, got no error") diff --git a/listvalidator/size_between.go b/listvalidator/size_between.go index 474d44d..bdd3e21 100644 --- a/listvalidator/size_between.go +++ b/listvalidator/size_between.go @@ -5,10 +5,10 @@ import ( "fmt" "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" ) -var _ tfsdk.AttributeValidator = sizeBetweenValidator{} +var _ validator.List = sizeBetweenValidator{} // sizeBetweenValidator validates that list contains at least min elements // and at most max elements. @@ -28,20 +28,19 @@ func (v sizeBetweenValidator) MarkdownDescription(ctx context.Context) string { } // Validate performs the validation. -func (v sizeBetweenValidator) Validate(ctx context.Context, req tfsdk.ValidateAttributeRequest, resp *tfsdk.ValidateAttributeResponse) { - elems, ok := validateList(ctx, req, resp) - if !ok { +func (v sizeBetweenValidator) ValidateList(ctx context.Context, req validator.ListRequest, resp *validator.ListResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { return } + elems := req.ConfigValue.Elements() + if len(elems) < v.min || len(elems) > v.max { resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( - req.AttributePath, + req.Path, v.Description(ctx), fmt.Sprintf("%d", len(elems)), )) - - return } } @@ -52,7 +51,7 @@ func (v sizeBetweenValidator) Validate(ctx context.Context, req tfsdk.ValidateAt // - Contains at least min elements and at most max elements. // // Null (unconfigured) and unknown (known after apply) values are skipped. -func SizeBetween(min, max int) tfsdk.AttributeValidator { +func SizeBetween(min, max int) validator.List { return sizeBetweenValidator{ min: min, max: max, diff --git a/listvalidator/size_between_example_test.go b/listvalidator/size_between_example_test.go index 9629c39..3d2201f 100644 --- a/listvalidator/size_between_example_test.go +++ b/listvalidator/size_between_example_test.go @@ -2,20 +2,19 @@ package listvalidator_test import ( "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" ) func ExampleSizeBetween() { - // Used within a GetSchema method of a DataSource, Provider, or Resource - _ = tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "example_attr": { - Required: true, - Type: types.ListType{ - ElemType: types.StringType, - }, - Validators: []tfsdk.AttributeValidator{ + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.ListAttribute{ + ElementType: types.StringType, + Required: true, + Validators: []validator.List{ // Validate this list must contain at least 2 and at most 4 elements. listvalidator.SizeBetween(2, 4), }, diff --git a/listvalidator/size_between_test.go b/listvalidator/size_between_test.go index 959724e..8b56e29 100644 --- a/listvalidator/size_between_test.go +++ b/listvalidator/size_between_test.go @@ -6,7 +6,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" ) @@ -14,16 +14,12 @@ func TestSizeBetweenValidator(t *testing.T) { t.Parallel() type testCase struct { - val attr.Value + val types.List min int max int expectError bool } tests := map[string]testCase{ - "not a List": { - val: types.BoolValue(true), - expectError: true, - }, "List unknown": { val: types.ListUnknown( types.StringType, @@ -112,13 +108,13 @@ func TestSizeBetweenValidator(t *testing.T) { for name, test := range tests { name, test := name, test t.Run(name, func(t *testing.T) { - request := tfsdk.ValidateAttributeRequest{ - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - AttributeConfig: test.val, + request := validator.ListRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, } - response := tfsdk.ValidateAttributeResponse{} - SizeBetween(test.min, test.max).Validate(context.TODO(), request, &response) + response := validator.ListResponse{} + SizeBetween(test.min, test.max).ValidateList(context.TODO(), request, &response) if !response.Diagnostics.HasError() && test.expectError { t.Fatal("expected error, got no error") diff --git a/listvalidator/type_validation.go b/listvalidator/type_validation.go deleted file mode 100644 index f437258..0000000 --- a/listvalidator/type_validation.go +++ /dev/null @@ -1,28 +0,0 @@ -package listvalidator - -import ( - "context" - - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" -) - -// validateList ensures that the request contains a List value. -func validateList(ctx context.Context, request tfsdk.ValidateAttributeRequest, response *tfsdk.ValidateAttributeResponse) ([]attr.Value, bool) { - var l types.List - - diags := tfsdk.ValueAs(ctx, request.AttributeConfig, &l) - - if diags.HasError() { - response.Diagnostics.Append(diags...) - - return nil, false - } - - if l.IsUnknown() || l.IsNull() { - return nil, false - } - - return l.Elements(), true -} diff --git a/listvalidator/type_validation_test.go b/listvalidator/type_validation_test.go deleted file mode 100644 index dd72311..0000000 --- a/listvalidator/type_validation_test.go +++ /dev/null @@ -1,86 +0,0 @@ -package listvalidator - -import ( - "context" - "testing" - - "github.com/google/go-cmp/cmp" - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" -) - -func TestValidateList(t *testing.T) { - t.Parallel() - - testCases := map[string]struct { - request tfsdk.ValidateAttributeRequest - expectedListElems []attr.Value - expectedOk bool - }{ - "invalid-type": { - request: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.BoolValue(true), - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - }, - expectedListElems: nil, - expectedOk: false, - }, - "list-null": { - request: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.ListNull(types.StringType), - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - }, - expectedListElems: nil, - expectedOk: false, - }, - "list-unknown": { - request: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.ListUnknown(types.StringType), - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - }, - expectedListElems: nil, - expectedOk: false, - }, - "list-value": { - request: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.ListValueMust( - types.StringType, - []attr.Value{ - types.StringValue("first"), - types.StringValue("second"), - }, - ), - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - }, - expectedListElems: []attr.Value{ - types.StringValue("first"), - types.StringValue("second"), - }, - expectedOk: true, - }, - } - - for name, testCase := range testCases { - name, testCase := name, testCase - - t.Run(name, func(t *testing.T) { - t.Parallel() - - gotListElems, gotOk := validateList(context.Background(), testCase.request, &tfsdk.ValidateAttributeResponse{}) - - if diff := cmp.Diff(gotListElems, testCase.expectedListElems); diff != "" { - t.Errorf("unexpected float64 difference: %s", diff) - } - - if diff := cmp.Diff(gotOk, testCase.expectedOk); diff != "" { - t.Errorf("unexpected ok difference: %s", diff) - } - }) - } -} diff --git a/listvalidator/value_float64s_are.go b/listvalidator/value_float64s_are.go new file mode 100644 index 0000000..dc8f45f --- /dev/null +++ b/listvalidator/value_float64s_are.go @@ -0,0 +1,116 @@ +package listvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// ValueFloat64sAre returns an validator which ensures that any configured +// Float64 values passes each Float64 validator. +// +// Null (unconfigured) and unknown (known after apply) values are skipped. +func ValueFloat64sAre(elementValidators ...validator.Float64) validator.List { + return valueFloat64sAreValidator{ + elementValidators: elementValidators, + } +} + +var _ validator.List = valueFloat64sAreValidator{} + +// valueFloat64sAreValidator validates that each Float64 member validates against each of the value validators. +type valueFloat64sAreValidator struct { + elementValidators []validator.Float64 +} + +// Description describes the validation in plain text formatting. +func (v valueFloat64sAreValidator) Description(ctx context.Context) string { + var descriptions []string + + for _, elementValidator := range v.elementValidators { + descriptions = append(descriptions, elementValidator.Description(ctx)) + } + + return fmt.Sprintf("element value must satisfy all validations: %s", strings.Join(descriptions, " + ")) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (v valueFloat64sAreValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +// ValidateFloat64 performs the validation. +func (v valueFloat64sAreValidator) ValidateList(ctx context.Context, req validator.ListRequest, resp *validator.ListResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + _, ok := req.ConfigValue.ElementType(ctx).(types.Float64Typable) + + if !ok { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Validator for Element Type", + "While performing schema-based validation, an unexpected error occurred. "+ + "The attribute declares a Float64 values validator, however its values do not implement types.Float64Type or the types.Float64Typable interface for custom Float64 types. "+ + "Use the appropriate values validator that matches the element type. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Path: %s\n", req.Path.String())+ + fmt.Sprintf("Element Type: %T\n", req.ConfigValue.ElementType(ctx)), + ) + + return + } + + for idx, element := range req.ConfigValue.Elements() { + elementPath := req.Path.AtListIndex(idx) + + elementValuable, ok := element.(types.Float64Valuable) + + // The check above should have prevented this, but raise an error + // instead of a type assertion panic or skipping the element. Any issue + // here likely indicates something wrong in the framework itself. + if !ok { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Validator for Element Value", + "While performing schema-based validation, an unexpected error occurred. "+ + "The attribute declares a Float64 values validator, however its values do not implement types.Float64Type or the types.Float64Typable interface for custom Float64 types. "+ + "This is likely an issue with terraform-plugin-framework and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Path: %s\n", req.Path.String())+ + fmt.Sprintf("Element Type: %T\n", req.ConfigValue.ElementType(ctx))+ + fmt.Sprintf("Element Value Type: %T\n", element), + ) + + return + } + + elementValue, diags := elementValuable.ToFloat64Value(ctx) + + resp.Diagnostics.Append(diags...) + + // Only return early if the new diagnostics indicate an issue since + // it likely will be the same for all elements. + if diags.HasError() { + return + } + + elementReq := validator.Float64Request{ + Path: elementPath, + PathExpression: elementPath.Expression(), + ConfigValue: elementValue, + Config: req.Config, + } + + for _, elementValidator := range v.elementValidators { + elementResp := &validator.Float64Response{} + + elementValidator.ValidateFloat64(ctx, elementReq, elementResp) + + resp.Diagnostics.Append(elementResp.Diagnostics...) + } + } +} diff --git a/listvalidator/value_float64s_are_example_test.go b/listvalidator/value_float64s_are_example_test.go new file mode 100644 index 0000000..295672e --- /dev/null +++ b/listvalidator/value_float64s_are_example_test.go @@ -0,0 +1,25 @@ +package listvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/float64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ExampleValueFloat64sAre() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.ListAttribute{ + ElementType: types.Float64Type, + Required: true, + Validators: []validator.List{ + // Validate this List must contain Float64 values which are at least 1.2. + listvalidator.ValueFloat64sAre(float64validator.AtLeast(1.2)), + }, + }, + }, + } +} diff --git a/listvalidator/value_float64s_are_test.go b/listvalidator/value_float64s_are_test.go new file mode 100644 index 0000000..857e7b2 --- /dev/null +++ b/listvalidator/value_float64s_are_test.go @@ -0,0 +1,143 @@ +package listvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/float64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" +) + +func TestValueFloat64sAreValidatorValidateList(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + val types.List + elementValidators []validator.Float64 + expectedDiagnostics diag.Diagnostics + }{ + "no element validators": { + val: types.ListValueMust( + types.Float64Type, + []attr.Value{ + types.Float64Value(1), + types.Float64Value(2), + }, + ), + }, + "List unknown": { + val: types.ListUnknown( + types.Float64Type, + ), + elementValidators: []validator.Float64{ + float64validator.AtLeast(1), + }, + }, + "List null": { + val: types.ListNull( + types.Float64Type, + ), + elementValidators: []validator.Float64{ + float64validator.AtLeast(1), + }, + }, + "List elements invalid": { + val: types.ListValueMust( + types.Float64Type, + []attr.Value{ + types.Float64Value(1), + types.Float64Value(2), + }, + ), + elementValidators: []validator.Float64{ + float64validator.AtLeast(3), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtListIndex(0), + "Invalid Attribute Value", + "Attribute test[0] value must be at least 3.000000, got: 1.000000", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtListIndex(1), + "Invalid Attribute Value", + "Attribute test[1] value must be at least 3.000000, got: 2.000000", + ), + }, + }, + "List elements invalid for multiple validator": { + val: types.ListValueMust( + types.Float64Type, + []attr.Value{ + types.Float64Value(1), + types.Float64Value(2), + }, + ), + elementValidators: []validator.Float64{ + float64validator.AtLeast(3), + float64validator.AtLeast(4), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtListIndex(0), + "Invalid Attribute Value", + "Attribute test[0] value must be at least 3.000000, got: 1.000000", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtListIndex(0), + "Invalid Attribute Value", + "Attribute test[0] value must be at least 4.000000, got: 1.000000", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtListIndex(1), + "Invalid Attribute Value", + "Attribute test[1] value must be at least 3.000000, got: 2.000000", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtListIndex(1), + "Invalid Attribute Value", + "Attribute test[1] value must be at least 4.000000, got: 2.000000", + ), + }, + }, + "List elements valid": { + val: types.ListValueMust( + types.Float64Type, + []attr.Value{ + types.Float64Value(1), + types.Float64Value(2), + }, + ), + elementValidators: []validator.Float64{ + float64validator.AtLeast(1), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + request := validator.ListRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: testCase.val, + } + response := validator.ListResponse{} + listvalidator.ValueFloat64sAre(testCase.elementValidators...).ValidateList(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, testCase.expectedDiagnostics); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/listvalidator/value_int64s_are.go b/listvalidator/value_int64s_are.go new file mode 100644 index 0000000..e282587 --- /dev/null +++ b/listvalidator/value_int64s_are.go @@ -0,0 +1,116 @@ +package listvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// ValueInt64sAre returns an validator which ensures that any configured +// Int64 values passes each Int64 validator. +// +// Null (unconfigured) and unknown (known after apply) values are skipped. +func ValueInt64sAre(elementValidators ...validator.Int64) validator.List { + return valueInt64sAreValidator{ + elementValidators: elementValidators, + } +} + +var _ validator.List = valueInt64sAreValidator{} + +// valueInt64sAreValidator validates that each Int64 member validates against each of the value validators. +type valueInt64sAreValidator struct { + elementValidators []validator.Int64 +} + +// Description describes the validation in plain text formatting. +func (v valueInt64sAreValidator) Description(ctx context.Context) string { + var descriptions []string + + for _, elementValidator := range v.elementValidators { + descriptions = append(descriptions, elementValidator.Description(ctx)) + } + + return fmt.Sprintf("element value must satisfy all validations: %s", strings.Join(descriptions, " + ")) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (v valueInt64sAreValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +// ValidateInt64 performs the validation. +func (v valueInt64sAreValidator) ValidateList(ctx context.Context, req validator.ListRequest, resp *validator.ListResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + _, ok := req.ConfigValue.ElementType(ctx).(types.Int64Typable) + + if !ok { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Validator for Element Type", + "While performing schema-based validation, an unexpected error occurred. "+ + "The attribute declares a Int64 values validator, however its values do not implement types.Int64Type or the types.Int64Typable interface for custom Int64 types. "+ + "Use the appropriate values validator that matches the element type. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Path: %s\n", req.Path.String())+ + fmt.Sprintf("Element Type: %T\n", req.ConfigValue.ElementType(ctx)), + ) + + return + } + + for idx, element := range req.ConfigValue.Elements() { + elementPath := req.Path.AtListIndex(idx) + + elementValuable, ok := element.(types.Int64Valuable) + + // The check above should have prevented this, but raise an error + // instead of a type assertion panic or skipping the element. Any issue + // here likely indicates something wrong in the framework itself. + if !ok { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Validator for Element Value", + "While performing schema-based validation, an unexpected error occurred. "+ + "The attribute declares a Int64 values validator, however its values do not implement types.Int64Type or the types.Int64Typable interface for custom Int64 types. "+ + "This is likely an issue with terraform-plugin-framework and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Path: %s\n", req.Path.String())+ + fmt.Sprintf("Element Type: %T\n", req.ConfigValue.ElementType(ctx))+ + fmt.Sprintf("Element Value Type: %T\n", element), + ) + + return + } + + elementValue, diags := elementValuable.ToInt64Value(ctx) + + resp.Diagnostics.Append(diags...) + + // Only return early if the new diagnostics indicate an issue since + // it likely will be the same for all elements. + if diags.HasError() { + return + } + + elementReq := validator.Int64Request{ + Path: elementPath, + PathExpression: elementPath.Expression(), + ConfigValue: elementValue, + Config: req.Config, + } + + for _, elementValidator := range v.elementValidators { + elementResp := &validator.Int64Response{} + + elementValidator.ValidateInt64(ctx, elementReq, elementResp) + + resp.Diagnostics.Append(elementResp.Diagnostics...) + } + } +} diff --git a/listvalidator/value_int64s_are_example_test.go b/listvalidator/value_int64s_are_example_test.go new file mode 100644 index 0000000..67a64ba --- /dev/null +++ b/listvalidator/value_int64s_are_example_test.go @@ -0,0 +1,25 @@ +package listvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ExampleValueInt64sAre() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.ListAttribute{ + ElementType: types.Int64Type, + Required: true, + Validators: []validator.List{ + // Validate this List must contain Int64 values which are at least 1. + listvalidator.ValueInt64sAre(int64validator.AtLeast(1)), + }, + }, + }, + } +} diff --git a/listvalidator/value_int64s_are_test.go b/listvalidator/value_int64s_are_test.go new file mode 100644 index 0000000..3bdddcc --- /dev/null +++ b/listvalidator/value_int64s_are_test.go @@ -0,0 +1,138 @@ +package listvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" +) + +func TestValueInt64sAreValidatorValidateList(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + val types.List + elementValidators []validator.Int64 + expectedDiagnostics diag.Diagnostics + }{ + "no element validators": { + val: types.ListValueMust( + types.Int64Type, + []attr.Value{ + types.Int64Value(1), + types.Int64Value(2), + }, + ), + }, + "List unknown": { + val: types.ListUnknown( + types.Int64Type, + ), + elementValidators: []validator.Int64{ + int64validator.AtLeast(1), + }, + }, + "List null": { + val: types.ListNull( + types.Int64Type, + ), + elementValidators: []validator.Int64{ + int64validator.AtLeast(1), + }, + }, + "List elements invalid": { + val: types.ListValueMust( + types.Int64Type, + []attr.Value{ + types.Int64Value(1), + types.Int64Value(2), + }, + ), + elementValidators: []validator.Int64{ + int64validator.AtLeast(2), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtListIndex(0), + "Invalid Attribute Value", + "Attribute test[0] value must be at least 2, got: 1", + ), + }, + }, + "List elements invalid for multiple validator": { + val: types.ListValueMust( + types.Int64Type, + []attr.Value{ + types.Int64Value(1), + types.Int64Value(2), + }, + ), + elementValidators: []validator.Int64{ + int64validator.AtLeast(3), + int64validator.AtLeast(4), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtListIndex(0), + "Invalid Attribute Value", + "Attribute test[0] value must be at least 3, got: 1", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtListIndex(0), + "Invalid Attribute Value", + "Attribute test[0] value must be at least 4, got: 1", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtListIndex(1), + "Invalid Attribute Value", + "Attribute test[1] value must be at least 3, got: 2", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtListIndex(1), + "Invalid Attribute Value", + "Attribute test[1] value must be at least 4, got: 2", + ), + }, + }, + "List elements valid": { + val: types.ListValueMust( + types.Int64Type, + []attr.Value{ + types.Int64Value(1), + types.Int64Value(2), + }, + ), + elementValidators: []validator.Int64{ + int64validator.AtLeast(1), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + request := validator.ListRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: testCase.val, + } + response := validator.ListResponse{} + listvalidator.ValueInt64sAre(testCase.elementValidators...).ValidateList(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, testCase.expectedDiagnostics); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/listvalidator/value_lists_are.go b/listvalidator/value_lists_are.go new file mode 100644 index 0000000..a3cf2d4 --- /dev/null +++ b/listvalidator/value_lists_are.go @@ -0,0 +1,116 @@ +package listvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// ValueListsAre returns an validator which ensures that any configured +// List values passes each List validator. +// +// Null (unconfigured) and unknown (known after apply) values are skipped. +func ValueListsAre(elementValidators ...validator.List) validator.List { + return valueListsAreValidator{ + elementValidators: elementValidators, + } +} + +var _ validator.List = valueListsAreValidator{} + +// valueListsAreValidator validates that each List member validates against each of the value validators. +type valueListsAreValidator struct { + elementValidators []validator.List +} + +// Description describes the validation in plain text formatting. +func (v valueListsAreValidator) Description(ctx context.Context) string { + var descriptions []string + + for _, elementValidator := range v.elementValidators { + descriptions = append(descriptions, elementValidator.Description(ctx)) + } + + return fmt.Sprintf("element value must satisfy all validations: %s", strings.Join(descriptions, " + ")) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (v valueListsAreValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +// ValidateSet performs the validation. +func (v valueListsAreValidator) ValidateList(ctx context.Context, req validator.ListRequest, resp *validator.ListResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + _, ok := req.ConfigValue.ElementType(ctx).(types.ListTypable) + + if !ok { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Validator for Element Type", + "While performing schema-based validation, an unexpected error occurred. "+ + "The attribute declares a List values validator, however its values do not implement types.ListType or the types.ListTypable interface for custom List types. "+ + "Use the appropriate values validator that matches the element type. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Path: %s\n", req.Path.String())+ + fmt.Sprintf("Element Type: %T\n", req.ConfigValue.ElementType(ctx)), + ) + + return + } + + for idx, element := range req.ConfigValue.Elements() { + elementPath := req.Path.AtListIndex(idx) + + elementValuable, ok := element.(types.ListValuable) + + // The check above should have prevented this, but raise an error + // instead of a type assertion panic or skipping the element. Any issue + // here likely indicates something wrong in the framework itself. + if !ok { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Validator for Element Value", + "While performing schema-based validation, an unexpected error occurred. "+ + "The attribute declares a List values validator, however its values do not implement types.ListType or the types.ListTypable interface for custom List types. "+ + "This is likely an issue with terraform-plugin-framework and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Path: %s\n", req.Path.String())+ + fmt.Sprintf("Element Type: %T\n", req.ConfigValue.ElementType(ctx))+ + fmt.Sprintf("Element Value Type: %T\n", element), + ) + + return + } + + elementValue, diags := elementValuable.ToListValue(ctx) + + resp.Diagnostics.Append(diags...) + + // Only return early if the new diagnostics indicate an issue since + // it likely will be the same for all elements. + if diags.HasError() { + return + } + + elementReq := validator.ListRequest{ + Path: elementPath, + PathExpression: elementPath.Expression(), + ConfigValue: elementValue, + Config: req.Config, + } + + for _, elementValidator := range v.elementValidators { + elementResp := &validator.ListResponse{} + + elementValidator.ValidateList(ctx, elementReq, elementResp) + + resp.Diagnostics.Append(elementResp.Diagnostics...) + } + } +} diff --git a/listvalidator/value_lists_are_example_test.go b/listvalidator/value_lists_are_example_test.go new file mode 100644 index 0000000..afec33e --- /dev/null +++ b/listvalidator/value_lists_are_example_test.go @@ -0,0 +1,29 @@ +package listvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ExampleValueListsAre() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.ListAttribute{ + // This List has values of List of Strings. + // Roughly equivalent to [][]string. + ElementType: types.ListType{ + ElemType: types.StringType, + }, + Required: true, + Validators: []validator.List{ + // Validate this List must contain List elements + // which have at least 1 String element. + listvalidator.ValueListsAre(listvalidator.SizeAtLeast(1)), + }, + }, + }, + } +} diff --git a/listvalidator/value_lists_are_test.go b/listvalidator/value_lists_are_test.go new file mode 100644 index 0000000..9eabc03 --- /dev/null +++ b/listvalidator/value_lists_are_test.go @@ -0,0 +1,190 @@ +package listvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" +) + +func TestValueListsAreValidatorValidateList(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + val types.List + elementValidators []validator.List + expectedDiagnostics diag.Diagnostics + }{ + "no element validators": { + val: types.ListValueMust( + types.ListType{ElemType: types.StringType}, + []attr.Value{ + types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("third"), + types.StringValue("fourth"), + }, + ), + }, + ), + }, + "List unknown": { + val: types.ListUnknown( + types.StringType, + ), + elementValidators: []validator.List{ + listvalidator.SizeAtLeast(1), + }, + }, + "List null": { + val: types.ListNull( + types.StringType, + ), + elementValidators: []validator.List{ + listvalidator.SizeAtLeast(1), + }, + }, + "List elements invalid": { + val: types.ListValueMust( + types.ListType{ElemType: types.StringType}, + []attr.Value{ + types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("third"), + types.StringValue("fourth"), + }, + ), + }, + ), + elementValidators: []validator.List{ + listvalidator.SizeAtLeast(3), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtListIndex(0), + "Invalid Attribute Value", + "Attribute test[0] list must contain at least 3 elements, got: 2", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtListIndex(1), + "Invalid Attribute Value", + "Attribute test[1] list must contain at least 3 elements, got: 2", + ), + }, + }, + "List elements invalid for multiple validator": { + val: types.ListValueMust( + types.ListType{ElemType: types.StringType}, + []attr.Value{ + types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("third"), + types.StringValue("fourth"), + }, + ), + }, + ), + elementValidators: []validator.List{ + listvalidator.SizeAtLeast(3), + listvalidator.SizeAtLeast(4), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtListIndex(0), + "Invalid Attribute Value", + "Attribute test[0] list must contain at least 3 elements, got: 2", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtListIndex(0), + "Invalid Attribute Value", + "Attribute test[0] list must contain at least 4 elements, got: 2", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtListIndex(1), + "Invalid Attribute Value", + "Attribute test[1] list must contain at least 3 elements, got: 2", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtListIndex(1), + "Invalid Attribute Value", + "Attribute test[1] list must contain at least 4 elements, got: 2", + ), + }, + }, + "List elements valid": { + val: types.ListValueMust( + types.ListType{ElemType: types.StringType}, + []attr.Value{ + types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("third"), + types.StringValue("fourth"), + }, + ), + }, + ), + elementValidators: []validator.List{ + listvalidator.SizeAtLeast(1), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + request := validator.ListRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: testCase.val, + } + response := validator.ListResponse{} + listvalidator.ValueListsAre(testCase.elementValidators...).ValidateList(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, testCase.expectedDiagnostics); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/listvalidator/value_maps_are.go b/listvalidator/value_maps_are.go new file mode 100644 index 0000000..25148a1 --- /dev/null +++ b/listvalidator/value_maps_are.go @@ -0,0 +1,116 @@ +package listvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// ValueMapsAre returns an validator which ensures that any configured +// Map values passes each Map validator. +// +// Null (unconfigured) and unknown (known after apply) values are skipped. +func ValueMapsAre(elementValidators ...validator.Map) validator.List { + return valueMapsAreValidator{ + elementValidators: elementValidators, + } +} + +var _ validator.List = valueMapsAreValidator{} + +// valueMapsAreValidator validates that each Map member validates against each of the value validators. +type valueMapsAreValidator struct { + elementValidators []validator.Map +} + +// Description describes the validation in plain text formatting. +func (v valueMapsAreValidator) Description(ctx context.Context) string { + var descriptions []string + + for _, elementValidator := range v.elementValidators { + descriptions = append(descriptions, elementValidator.Description(ctx)) + } + + return fmt.Sprintf("element value must satisfy all validations: %s", strings.Join(descriptions, " + ")) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (v valueMapsAreValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +// ValidateMap performs the validation. +func (v valueMapsAreValidator) ValidateList(ctx context.Context, req validator.ListRequest, resp *validator.ListResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + _, ok := req.ConfigValue.ElementType(ctx).(types.MapTypable) + + if !ok { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Validator for Element Type", + "While performing schema-based validation, an unexpected error occurred. "+ + "The attribute declares a Map values validator, however its values do not implement types.MapType or the types.MapTypable interface for custom Map types. "+ + "Use the appropriate values validator that matches the element type. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Path: %s\n", req.Path.String())+ + fmt.Sprintf("Element Type: %T\n", req.ConfigValue.ElementType(ctx)), + ) + + return + } + + for idx, element := range req.ConfigValue.Elements() { + elementPath := req.Path.AtListIndex(idx) + + elementValuable, ok := element.(types.MapValuable) + + // The check above should have prevented this, but raise an error + // instead of a type assertion panic or skipping the element. Any issue + // here likely indicates something wrong in the framework itself. + if !ok { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Validator for Element Value", + "While performing schema-based validation, an unexpected error occurred. "+ + "The attribute declares a Map values validator, however its values do not implement types.MapType or the types.MapTypable interface for custom Map types. "+ + "This is likely an issue with terraform-plugin-framework and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Path: %s\n", req.Path.String())+ + fmt.Sprintf("Element Type: %T\n", req.ConfigValue.ElementType(ctx))+ + fmt.Sprintf("Element Value Type: %T\n", element), + ) + + return + } + + elementValue, diags := elementValuable.ToMapValue(ctx) + + resp.Diagnostics.Append(diags...) + + // Only return early if the new diagnostics indicate an issue since + // it likely will be the same for all elements. + if diags.HasError() { + return + } + + elementReq := validator.MapRequest{ + Path: elementPath, + PathExpression: elementPath.Expression(), + ConfigValue: elementValue, + Config: req.Config, + } + + for _, elementValidator := range v.elementValidators { + elementResp := &validator.MapResponse{} + + elementValidator.ValidateMap(ctx, elementReq, elementResp) + + resp.Diagnostics.Append(elementResp.Diagnostics...) + } + } +} diff --git a/listvalidator/value_maps_are_example_test.go b/listvalidator/value_maps_are_example_test.go new file mode 100644 index 0000000..db70f77 --- /dev/null +++ b/listvalidator/value_maps_are_example_test.go @@ -0,0 +1,30 @@ +package listvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ExampleValueMapsAre() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.ListAttribute{ + // This List has values of Map of Strings. + // Roughly equivalent to []map[string]string. + ElementType: types.MapType{ + ElemType: types.StringType, + }, + Required: true, + Validators: []validator.List{ + // Validate this List must contain Map elements + // which have at least 1 element. + listvalidator.ValueMapsAre(mapvalidator.SizeAtLeast(1)), + }, + }, + }, + } +} diff --git a/listvalidator/value_maps_are_test.go b/listvalidator/value_maps_are_test.go new file mode 100644 index 0000000..f6c76a8 --- /dev/null +++ b/listvalidator/value_maps_are_test.go @@ -0,0 +1,191 @@ +package listvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" +) + +func TestValueMapsAreValidatorValidateList(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + val types.List + elementValidators []validator.Map + expectedDiagnostics diag.Diagnostics + }{ + "no element validators": { + val: types.ListValueMust( + types.MapType{ElemType: types.StringType}, + []attr.Value{ + types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("first"), + "key2": types.StringValue("second"), + }, + ), + types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("third"), + "key2": types.StringValue("fourth"), + }, + ), + }, + ), + }, + "List unknown": { + val: types.ListUnknown( + types.StringType, + ), + elementValidators: []validator.Map{ + mapvalidator.SizeAtLeast(1), + }, + }, + "List null": { + val: types.ListNull( + types.StringType, + ), + elementValidators: []validator.Map{ + mapvalidator.SizeAtLeast(1), + }, + }, + "List elements invalid": { + val: types.ListValueMust( + types.MapType{ElemType: types.StringType}, + []attr.Value{ + types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("first"), + "key2": types.StringValue("second"), + }, + ), + types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("third"), + "key2": types.StringValue("fourth"), + }, + ), + }, + ), + elementValidators: []validator.Map{ + mapvalidator.SizeAtLeast(3), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtListIndex(0), + "Invalid Attribute Value", + "Attribute test[0] map must contain at least 3 elements, got: 2", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtListIndex(1), + "Invalid Attribute Value", + "Attribute test[1] map must contain at least 3 elements, got: 2", + ), + }, + }, + "List elements invalid for multiple validator": { + val: types.ListValueMust( + types.MapType{ElemType: types.StringType}, + []attr.Value{ + types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("first"), + "key2": types.StringValue("second"), + }, + ), + types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("third"), + "key2": types.StringValue("fourth"), + }, + ), + }, + ), + elementValidators: []validator.Map{ + mapvalidator.SizeAtLeast(3), + mapvalidator.SizeAtLeast(4), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtListIndex(0), + "Invalid Attribute Value", + "Attribute test[0] map must contain at least 3 elements, got: 2", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtListIndex(0), + "Invalid Attribute Value", + "Attribute test[0] map must contain at least 4 elements, got: 2", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtListIndex(1), + "Invalid Attribute Value", + "Attribute test[1] map must contain at least 3 elements, got: 2", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtListIndex(1), + "Invalid Attribute Value", + "Attribute test[1] map must contain at least 4 elements, got: 2", + ), + }, + }, + "List elements valid": { + val: types.ListValueMust( + types.MapType{ElemType: types.StringType}, + []attr.Value{ + types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("first"), + "key2": types.StringValue("second"), + }, + ), + types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("third"), + "key2": types.StringValue("fourth"), + }, + ), + }, + ), + elementValidators: []validator.Map{ + mapvalidator.SizeAtLeast(1), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + request := validator.ListRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: testCase.val, + } + response := validator.ListResponse{} + listvalidator.ValueMapsAre(testCase.elementValidators...).ValidateList(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, testCase.expectedDiagnostics); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/listvalidator/value_numbers_are.go b/listvalidator/value_numbers_are.go new file mode 100644 index 0000000..7f89b9b --- /dev/null +++ b/listvalidator/value_numbers_are.go @@ -0,0 +1,116 @@ +package listvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// ValueNumbersAre returns an validator which ensures that any configured +// Number values passes each Number validator. +// +// Null (unconfigured) and unknown (known after apply) values are skipped. +func ValueNumbersAre(elementValidators ...validator.Number) validator.List { + return valueNumbersAreValidator{ + elementValidators: elementValidators, + } +} + +var _ validator.List = valueNumbersAreValidator{} + +// valueNumbersAreValidator validates that each Number member validates against each of the value validators. +type valueNumbersAreValidator struct { + elementValidators []validator.Number +} + +// Description describes the validation in plain text formatting. +func (v valueNumbersAreValidator) Description(ctx context.Context) string { + var descriptions []string + + for _, elementValidator := range v.elementValidators { + descriptions = append(descriptions, elementValidator.Description(ctx)) + } + + return fmt.Sprintf("element value must satisfy all validations: %s", strings.Join(descriptions, " + ")) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (v valueNumbersAreValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +// ValidateNumber performs the validation. +func (v valueNumbersAreValidator) ValidateList(ctx context.Context, req validator.ListRequest, resp *validator.ListResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + _, ok := req.ConfigValue.ElementType(ctx).(types.NumberTypable) + + if !ok { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Validator for Element Type", + "While performing schema-based validation, an unexpected error occurred. "+ + "The attribute declares a Number values validator, however its values do not implement types.NumberType or the types.NumberTypable interface for custom Number types. "+ + "Use the appropriate values validator that matches the element type. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Path: %s\n", req.Path.String())+ + fmt.Sprintf("Element Type: %T\n", req.ConfigValue.ElementType(ctx)), + ) + + return + } + + for idx, element := range req.ConfigValue.Elements() { + elementPath := req.Path.AtListIndex(idx) + + elementValuable, ok := element.(types.NumberValuable) + + // The check above should have prevented this, but raise an error + // instead of a type assertion panic or skipping the element. Any issue + // here likely indicates something wrong in the framework itself. + if !ok { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Validator for Element Value", + "While performing schema-based validation, an unexpected error occurred. "+ + "The attribute declares a Number values validator, however its values do not implement types.NumberType or the types.NumberTypable interface for custom Number types. "+ + "This is likely an issue with terraform-plugin-framework and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Path: %s\n", req.Path.String())+ + fmt.Sprintf("Element Type: %T\n", req.ConfigValue.ElementType(ctx))+ + fmt.Sprintf("Element Value Type: %T\n", element), + ) + + return + } + + elementValue, diags := elementValuable.ToNumberValue(ctx) + + resp.Diagnostics.Append(diags...) + + // Only return early if the new diagnostics indicate an issue since + // it likely will be the same for all elements. + if diags.HasError() { + return + } + + elementReq := validator.NumberRequest{ + Path: elementPath, + PathExpression: elementPath.Expression(), + ConfigValue: elementValue, + Config: req.Config, + } + + for _, elementValidator := range v.elementValidators { + elementResp := &validator.NumberResponse{} + + elementValidator.ValidateNumber(ctx, elementReq, elementResp) + + resp.Diagnostics.Append(elementResp.Diagnostics...) + } + } +} diff --git a/listvalidator/value_numbers_are_example_test.go b/listvalidator/value_numbers_are_example_test.go new file mode 100644 index 0000000..a92ce60 --- /dev/null +++ b/listvalidator/value_numbers_are_example_test.go @@ -0,0 +1,32 @@ +package listvalidator_test + +import ( + "math/big" + + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/numbervalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ExampleValueNumbersAre() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.ListAttribute{ + ElementType: types.NumberType, + Required: true, + Validators: []validator.List{ + // Validate this List must contain Number values which are 1.2 or 2.4. + listvalidator.ValueNumbersAre( + numbervalidator.OneOf( + big.NewFloat(1.2), + big.NewFloat(2.4), + ), + ), + }, + }, + }, + } +} diff --git a/listvalidator/value_numbers_are_test.go b/listvalidator/value_numbers_are_test.go new file mode 100644 index 0000000..e74deaa --- /dev/null +++ b/listvalidator/value_numbers_are_test.go @@ -0,0 +1,144 @@ +package listvalidator_test + +import ( + "context" + "math/big" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/numbervalidator" +) + +func TestValueNumbersAreValidatorValidateList(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + val types.List + elementValidators []validator.Number + expectedDiagnostics diag.Diagnostics + }{ + "no element validators": { + val: types.ListValueMust( + types.NumberType, + []attr.Value{ + types.NumberValue(big.NewFloat(1.2)), + types.NumberValue(big.NewFloat(2.4)), + }, + ), + }, + "List unknown": { + val: types.ListUnknown( + types.NumberType, + ), + elementValidators: []validator.Number{ + numbervalidator.OneOf(big.NewFloat(1.2)), + }, + }, + "List null": { + val: types.ListNull( + types.NumberType, + ), + elementValidators: []validator.Number{ + numbervalidator.OneOf(big.NewFloat(1.2)), + }, + }, + "List elements invalid": { + val: types.ListValueMust( + types.NumberType, + []attr.Value{ + types.NumberValue(big.NewFloat(1.2)), + types.NumberValue(big.NewFloat(2.4)), + }, + ), + elementValidators: []validator.Number{ + numbervalidator.OneOf(big.NewFloat(3.6)), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtListIndex(0), + "Invalid Attribute Value Match", + "Attribute test[0] value must be one of: [\"3.6\"], got: 1.2", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtListIndex(1), + "Invalid Attribute Value Match", + "Attribute test[1] value must be one of: [\"3.6\"], got: 2.4", + ), + }, + }, + "List elements invalid for multiple validator": { + val: types.ListValueMust( + types.NumberType, + []attr.Value{ + types.NumberValue(big.NewFloat(1.2)), + types.NumberValue(big.NewFloat(2.4)), + }, + ), + elementValidators: []validator.Number{ + numbervalidator.OneOf(big.NewFloat(3.6)), + numbervalidator.OneOf(big.NewFloat(4.8)), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtListIndex(0), + "Invalid Attribute Value Match", + "Attribute test[0] value must be one of: [\"3.6\"], got: 1.2", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtListIndex(0), + "Invalid Attribute Value Match", + "Attribute test[0] value must be one of: [\"4.8\"], got: 1.2", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtListIndex(1), + "Invalid Attribute Value Match", + "Attribute test[1] value must be one of: [\"3.6\"], got: 2.4", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtListIndex(1), + "Invalid Attribute Value Match", + "Attribute test[1] value must be one of: [\"4.8\"], got: 2.4", + ), + }, + }, + "List elements valid": { + val: types.ListValueMust( + types.NumberType, + []attr.Value{ + types.NumberValue(big.NewFloat(1.2)), + types.NumberValue(big.NewFloat(2.4)), + }, + ), + elementValidators: []validator.Number{ + numbervalidator.OneOf(big.NewFloat(1.2), big.NewFloat(2.4)), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + request := validator.ListRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: testCase.val, + } + response := validator.ListResponse{} + listvalidator.ValueNumbersAre(testCase.elementValidators...).ValidateList(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, testCase.expectedDiagnostics); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/listvalidator/value_sets_are.go b/listvalidator/value_sets_are.go new file mode 100644 index 0000000..f1f0272 --- /dev/null +++ b/listvalidator/value_sets_are.go @@ -0,0 +1,116 @@ +package listvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// ValueSetsAre returns an validator which ensures that any configured +// Set values passes each Set validator. +// +// Null (unconfigured) and unknown (known after apply) values are skipped. +func ValueSetsAre(elementValidators ...validator.Set) validator.List { + return valueSetsAreValidator{ + elementValidators: elementValidators, + } +} + +var _ validator.List = valueSetsAreValidator{} + +// valueSetsAreValidator validates that each set member validates against each of the value validators. +type valueSetsAreValidator struct { + elementValidators []validator.Set +} + +// Description describes the validation in plain text formatting. +func (v valueSetsAreValidator) Description(ctx context.Context) string { + var descriptions []string + + for _, elementValidator := range v.elementValidators { + descriptions = append(descriptions, elementValidator.Description(ctx)) + } + + return fmt.Sprintf("element value must satisfy all validations: %s", strings.Join(descriptions, " + ")) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (v valueSetsAreValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +// ValidateSet performs the validation. +func (v valueSetsAreValidator) ValidateList(ctx context.Context, req validator.ListRequest, resp *validator.ListResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + _, ok := req.ConfigValue.ElementType(ctx).(types.SetTypable) + + if !ok { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Validator for Element Type", + "While performing schema-based validation, an unexpected error occurred. "+ + "The attribute declares a Set values validator, however its values do not implement types.SetType or the types.SetTypable interface for custom Set types. "+ + "Use the appropriate values validator that matches the element type. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Path: %s\n", req.Path.String())+ + fmt.Sprintf("Element Type: %T\n", req.ConfigValue.ElementType(ctx)), + ) + + return + } + + for idx, element := range req.ConfigValue.Elements() { + elementPath := req.Path.AtListIndex(idx) + + elementValuable, ok := element.(types.SetValuable) + + // The check above should have prevented this, but raise an error + // instead of a type assertion panic or skipping the element. Any issue + // here likely indicates something wrong in the framework itself. + if !ok { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Validator for Element Value", + "While performing schema-based validation, an unexpected error occurred. "+ + "The attribute declares a Set values validator, however its values do not implement types.SetType or the types.SetTypable interface for custom Set types. "+ + "This is likely an issue with terraform-plugin-framework and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Path: %s\n", req.Path.String())+ + fmt.Sprintf("Element Type: %T\n", req.ConfigValue.ElementType(ctx))+ + fmt.Sprintf("Element Value Type: %T\n", element), + ) + + return + } + + elementValue, diags := elementValuable.ToSetValue(ctx) + + resp.Diagnostics.Append(diags...) + + // Only return early if the new diagnostics indicate an issue since + // it likely will be the same for all elements. + if diags.HasError() { + return + } + + elementReq := validator.SetRequest{ + Path: elementPath, + PathExpression: elementPath.Expression(), + ConfigValue: elementValue, + Config: req.Config, + } + + for _, elementValidator := range v.elementValidators { + elementResp := &validator.SetResponse{} + + elementValidator.ValidateSet(ctx, elementReq, elementResp) + + resp.Diagnostics.Append(elementResp.Diagnostics...) + } + } +} diff --git a/listvalidator/value_sets_are_example_test.go b/listvalidator/value_sets_are_example_test.go new file mode 100644 index 0000000..8fef9c2 --- /dev/null +++ b/listvalidator/value_sets_are_example_test.go @@ -0,0 +1,30 @@ +package listvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ExampleValueSetsAre() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.ListAttribute{ + // This List has values of Sets of Strings. + // Roughly equivalent to [][]string. + ElementType: types.SetType{ + ElemType: types.StringType, + }, + Required: true, + Validators: []validator.List{ + // Validate this List must contain Set elements + // which have at least 1 String element. + listvalidator.ValueSetsAre(setvalidator.SizeAtLeast(1)), + }, + }, + }, + } +} diff --git a/listvalidator/value_sets_are_test.go b/listvalidator/value_sets_are_test.go new file mode 100644 index 0000000..012ffcb --- /dev/null +++ b/listvalidator/value_sets_are_test.go @@ -0,0 +1,191 @@ +package listvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" +) + +func TestValueSetsAreValidatorValidateList(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + val types.List + elementValidators []validator.Set + expectedDiagnostics diag.Diagnostics + }{ + "no element validators": { + val: types.ListValueMust( + types.SetType{ElemType: types.StringType}, + []attr.Value{ + types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("third"), + types.StringValue("fourth"), + }, + ), + }, + ), + }, + "List unknown": { + val: types.ListUnknown( + types.StringType, + ), + elementValidators: []validator.Set{ + setvalidator.SizeAtLeast(1), + }, + }, + "List null": { + val: types.ListNull( + types.StringType, + ), + elementValidators: []validator.Set{ + setvalidator.SizeAtLeast(1), + }, + }, + "List elements invalid": { + val: types.ListValueMust( + types.SetType{ElemType: types.StringType}, + []attr.Value{ + types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("third"), + types.StringValue("fourth"), + }, + ), + }, + ), + elementValidators: []validator.Set{ + setvalidator.SizeAtLeast(3), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtListIndex(0), + "Invalid Attribute Value", + "Attribute test[0] set must contain at least 3 elements, got: 2", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtListIndex(1), + "Invalid Attribute Value", + "Attribute test[1] set must contain at least 3 elements, got: 2", + ), + }, + }, + "List elements invalid for multiple validator": { + val: types.ListValueMust( + types.SetType{ElemType: types.StringType}, + []attr.Value{ + types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("third"), + types.StringValue("fourth"), + }, + ), + }, + ), + elementValidators: []validator.Set{ + setvalidator.SizeAtLeast(3), + setvalidator.SizeAtLeast(4), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtListIndex(0), + "Invalid Attribute Value", + "Attribute test[0] set must contain at least 3 elements, got: 2", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtListIndex(0), + "Invalid Attribute Value", + "Attribute test[0] set must contain at least 4 elements, got: 2", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtListIndex(1), + "Invalid Attribute Value", + "Attribute test[1] set must contain at least 3 elements, got: 2", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtListIndex(1), + "Invalid Attribute Value", + "Attribute test[1] set must contain at least 4 elements, got: 2", + ), + }, + }, + "List elements valid": { + val: types.ListValueMust( + types.SetType{ElemType: types.StringType}, + []attr.Value{ + types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("third"), + types.StringValue("fourth"), + }, + ), + }, + ), + elementValidators: []validator.Set{ + setvalidator.SizeAtLeast(1), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + request := validator.ListRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: testCase.val, + } + response := validator.ListResponse{} + listvalidator.ValueSetsAre(testCase.elementValidators...).ValidateList(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, testCase.expectedDiagnostics); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/listvalidator/value_strings_are.go b/listvalidator/value_strings_are.go new file mode 100644 index 0000000..99aa91f --- /dev/null +++ b/listvalidator/value_strings_are.go @@ -0,0 +1,116 @@ +package listvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// ValueStringsAre returns an validator which ensures that any configured +// String values passes each String validator. +// +// Null (unconfigured) and unknown (known after apply) values are skipped. +func ValueStringsAre(elementValidators ...validator.String) validator.List { + return valueStringsAreValidator{ + elementValidators: elementValidators, + } +} + +var _ validator.List = valueStringsAreValidator{} + +// valueStringsAreValidator validates that each List member validates against each of the value validators. +type valueStringsAreValidator struct { + elementValidators []validator.String +} + +// Description describes the validation in plain text formatting. +func (v valueStringsAreValidator) Description(ctx context.Context) string { + var descriptions []string + + for _, elementValidator := range v.elementValidators { + descriptions = append(descriptions, elementValidator.Description(ctx)) + } + + return fmt.Sprintf("element value must satisfy all validations: %s", strings.Join(descriptions, " + ")) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (v valueStringsAreValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +// ValidateList performs the validation. +func (v valueStringsAreValidator) ValidateList(ctx context.Context, req validator.ListRequest, resp *validator.ListResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + _, ok := req.ConfigValue.ElementType(ctx).(types.StringTypable) + + if !ok { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Validator for Element Type", + "While performing schema-based validation, an unexpected error occurred. "+ + "The attribute declares a String values validator, however its values do not implement types.StringType or the types.StringTypable interface for custom String types. "+ + "Use the appropriate values validator that matches the element type. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Path: %s\n", req.Path.String())+ + fmt.Sprintf("Element Type: %T\n", req.ConfigValue.ElementType(ctx)), + ) + + return + } + + for idx, element := range req.ConfigValue.Elements() { + elementPath := req.Path.AtListIndex(idx) + + elementValuable, ok := element.(types.StringValuable) + + // The check above should have prevented this, but raise an error + // instead of a type assertion panic or skipping the element. Any issue + // here likely indicates something wrong in the framework itself. + if !ok { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Validator for Element Value", + "While performing schema-based validation, an unexpected error occurred. "+ + "The attribute declares a String values validator, however its values do not implement types.StringType or the types.StringTypable interface for custom String types. "+ + "This is likely an issue with terraform-plugin-framework and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Path: %s\n", req.Path.String())+ + fmt.Sprintf("Element Type: %T\n", req.ConfigValue.ElementType(ctx))+ + fmt.Sprintf("Element Value Type: %T\n", element), + ) + + return + } + + elementValue, diags := elementValuable.ToStringValue(ctx) + + resp.Diagnostics.Append(diags...) + + // Only return early if the new diagnostics indicate an issue since + // it likely will be the same for all elements. + if diags.HasError() { + return + } + + elementReq := validator.StringRequest{ + Path: elementPath, + PathExpression: elementPath.Expression(), + ConfigValue: elementValue, + Config: req.Config, + } + + for _, elementValidator := range v.elementValidators { + elementResp := &validator.StringResponse{} + + elementValidator.ValidateString(ctx, elementReq, elementResp) + + resp.Diagnostics.Append(elementResp.Diagnostics...) + } + } +} diff --git a/listvalidator/value_strings_are_example_test.go b/listvalidator/value_strings_are_example_test.go new file mode 100644 index 0000000..cd036eb --- /dev/null +++ b/listvalidator/value_strings_are_example_test.go @@ -0,0 +1,25 @@ +package listvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ExampleValueStringsAre() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.ListAttribute{ + ElementType: types.StringType, + Required: true, + Validators: []validator.List{ + // Validate this List must contain string values which are at least 3 characters. + listvalidator.ValueStringsAre(stringvalidator.LengthAtLeast(3)), + }, + }, + }, + } +} diff --git a/listvalidator/value_strings_are_test.go b/listvalidator/value_strings_are_test.go new file mode 100644 index 0000000..89cc533 --- /dev/null +++ b/listvalidator/value_strings_are_test.go @@ -0,0 +1,138 @@ +package listvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" +) + +func TestValueStringsAreValidatorValidateList(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + val types.List + elementValidators []validator.String + expectedDiagnostics diag.Diagnostics + }{ + "no element validators": { + val: types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + }, + "List unknown": { + val: types.ListUnknown( + types.StringType, + ), + elementValidators: []validator.String{ + stringvalidator.LengthAtLeast(6), + }, + }, + "List null": { + val: types.ListNull( + types.StringType, + ), + elementValidators: []validator.String{ + stringvalidator.LengthAtLeast(6), + }, + }, + "List elements invalid": { + val: types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + elementValidators: []validator.String{ + stringvalidator.LengthAtLeast(6), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtListIndex(0), + "Invalid Attribute Value Length", + "Attribute test[0] string length must be at least 6, got: 5", + ), + }, + }, + "List elements invalid for multiple validator": { + val: types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + elementValidators: []validator.String{ + stringvalidator.LengthAtLeast(7), + stringvalidator.LengthAtLeast(8), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtListIndex(0), + "Invalid Attribute Value Length", + "Attribute test[0] string length must be at least 7, got: 5", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtListIndex(0), + "Invalid Attribute Value Length", + "Attribute test[0] string length must be at least 8, got: 5", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtListIndex(1), + "Invalid Attribute Value Length", + "Attribute test[1] string length must be at least 7, got: 6", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtListIndex(1), + "Invalid Attribute Value Length", + "Attribute test[1] string length must be at least 8, got: 6", + ), + }, + }, + "List elements valid": { + val: types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + elementValidators: []validator.String{ + stringvalidator.LengthAtLeast(5), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + request := validator.ListRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: testCase.val, + } + response := validator.ListResponse{} + listvalidator.ValueStringsAre(testCase.elementValidators...).ValidateList(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, testCase.expectedDiagnostics); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/listvalidator/values_are.go b/listvalidator/values_are.go deleted file mode 100644 index c4ec20b..0000000 --- a/listvalidator/values_are.go +++ /dev/null @@ -1,66 +0,0 @@ -package listvalidator - -import ( - "context" - "fmt" - "strings" - - "github.com/hashicorp/terraform-plugin-framework/tfsdk" -) - -var _ tfsdk.AttributeValidator = valuesAreValidator{} - -// valuesAreValidator validates that each list member validates against each of the value validators. -type valuesAreValidator struct { - valueValidators []tfsdk.AttributeValidator -} - -// Description describes the validation in plain text formatting. -func (v valuesAreValidator) Description(ctx context.Context) string { - var descriptions []string - for _, validator := range v.valueValidators { - descriptions = append(descriptions, validator.Description(ctx)) - } - - return fmt.Sprintf("value must satisfy all validations: %s", strings.Join(descriptions, " + ")) -} - -// MarkdownDescription describes the validation in Markdown formatting. -func (v valuesAreValidator) MarkdownDescription(ctx context.Context) string { - return v.Description(ctx) -} - -// Validate performs the validation. -func (v valuesAreValidator) Validate(ctx context.Context, req tfsdk.ValidateAttributeRequest, resp *tfsdk.ValidateAttributeResponse) { - elems, ok := validateList(ctx, req, resp) - if !ok { - return - } - - for k, elem := range elems { - attrPath := req.AttributePath.AtListIndex(k) - request := tfsdk.ValidateAttributeRequest{ - AttributePath: attrPath, - AttributePathExpression: attrPath.Expression(), - AttributeConfig: elem, - Config: req.Config, - } - - for _, validator := range v.valueValidators { - validator.Validate(ctx, request, resp) - } - } -} - -// ValuesAre returns an AttributeValidator which ensures that any configured -// attribute value: -// -// - Is a List. -// - That contains list elements, each of which validate against each value validator. -// -// Null (unconfigured) and unknown (known after apply) values are skipped. -func ValuesAre(valueValidators ...tfsdk.AttributeValidator) tfsdk.AttributeValidator { - return valuesAreValidator{ - valueValidators: valueValidators, - } -} diff --git a/listvalidator/values_are_example_test.go b/listvalidator/values_are_example_test.go deleted file mode 100644 index 5603074..0000000 --- a/listvalidator/values_are_example_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package listvalidator_test - -import ( - "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" - "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" -) - -func ExampleValuesAre() { - // Used within a GetSchema method of a DataSource, Provider, or Resource - _ = tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "example_attr": { - Required: true, - Type: types.ListType{ - ElemType: types.StringType, - }, - Validators: []tfsdk.AttributeValidator{ - // Validate this list must contain string values which are at least 3 characters. - listvalidator.ValuesAre(stringvalidator.LengthAtLeast(3)), - }, - }, - }, - } -} diff --git a/listvalidator/values_are_test.go b/listvalidator/values_are_test.go deleted file mode 100644 index 4f88db6..0000000 --- a/listvalidator/values_are_test.go +++ /dev/null @@ -1,119 +0,0 @@ -package listvalidator - -import ( - "context" - "testing" - - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" - - "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" - "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" -) - -func TestValuesAreValidator(t *testing.T) { - t.Parallel() - - type testCase struct { - val attr.Value - valuesAreValidators []tfsdk.AttributeValidator - expectError bool - } - tests := map[string]testCase{ - "not List": { - val: types.SetValueMust( - types.StringType, - []attr.Value{}, - ), - expectError: true, - }, - "List unknown": { - val: types.ListUnknown( - types.StringType, - ), - expectError: false, - }, - "List null": { - val: types.ListNull( - types.StringType, - ), - expectError: false, - }, - "List elems invalid": { - val: types.ListValueMust( - types.StringType, - []attr.Value{ - types.StringValue("first"), - types.StringValue("second"), - }, - ), - valuesAreValidators: []tfsdk.AttributeValidator{ - stringvalidator.LengthAtLeast(6), - }, - expectError: true, - }, - "List elems invalid for second validator": { - val: types.ListValueMust( - types.StringType, - []attr.Value{ - types.StringValue("first"), - types.StringValue("second"), - }, - ), - valuesAreValidators: []tfsdk.AttributeValidator{ - stringvalidator.LengthAtLeast(2), - stringvalidator.LengthAtLeast(6), - }, - expectError: true, - }, - "List elems wrong type for validator": { - val: types.ListValueMust( - types.StringType, - []attr.Value{ - types.StringValue("first"), - types.StringValue("second"), - }, - ), - valuesAreValidators: []tfsdk.AttributeValidator{ - int64validator.AtLeast(6), - }, - expectError: true, - }, - "List elems valid": { - val: types.ListValueMust( - types.StringType, - []attr.Value{ - types.StringValue("first"), - types.StringValue("second"), - }, - ), - valuesAreValidators: []tfsdk.AttributeValidator{ - stringvalidator.LengthAtLeast(5), - }, - expectError: false, - }, - } - - for name, test := range tests { - name, test := name, test - t.Run(name, func(t *testing.T) { - request := tfsdk.ValidateAttributeRequest{ - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - AttributeConfig: test.val, - } - response := tfsdk.ValidateAttributeResponse{} - ValuesAre(test.valuesAreValidators...).Validate(context.TODO(), request, &response) - - if !response.Diagnostics.HasError() && test.expectError { - t.Fatal("expected error, got no error") - } - - if response.Diagnostics.HasError() && !test.expectError { - t.Fatalf("got unexpected error: %s", response.Diagnostics) - } - }) - } -} diff --git a/mapvalidator/all.go b/mapvalidator/all.go new file mode 100644 index 0000000..11aebd5 --- /dev/null +++ b/mapvalidator/all.go @@ -0,0 +1,54 @@ +package mapvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// All returns a validator which ensures that any configured attribute value +// 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 ...validator.Map) validator.Map { + return allValidator{ + validators: validators, + } +} + +var _ validator.Map = allValidator{} + +// allValidator implements the validator. +type allValidator struct { + validators []validator.Map +} + +// 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) +} + +// ValidateMap performs the validation. +func (v allValidator) ValidateMap(ctx context.Context, req validator.MapRequest, resp *validator.MapResponse) { + for _, subValidator := range v.validators { + validateResp := &validator.MapResponse{} + + subValidator.ValidateMap(ctx, req, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) + } +} diff --git a/mapvalidator/all_example_test.go b/mapvalidator/all_example_test.go new file mode 100644 index 0000000..76bcba7 --- /dev/null +++ b/mapvalidator/all_example_test.go @@ -0,0 +1,32 @@ +package mapvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ExampleAll() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.MapAttribute{ + ElementType: types.StringType, + Required: true, + Validators: []validator.Map{ + // Validate this Map value must either be: + // - More than 5 elements + // - At least 2 elements, but not more than 3 elements + mapvalidator.Any( + mapvalidator.SizeAtLeast(5), + mapvalidator.All( + mapvalidator.SizeAtLeast(2), + mapvalidator.SizeAtMost(3), + ), + ), + }, + }, + }, + } +} diff --git a/mapvalidator/all_test.go b/mapvalidator/all_test.go new file mode 100644 index 0000000..2ba15b1 --- /dev/null +++ b/mapvalidator/all_test.go @@ -0,0 +1,83 @@ +package mapvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" +) + +func TestAllValidatorValidateMap(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.Map + validators []validator.Map + expected diag.Diagnostics + } + tests := map[string]testCase{ + "invalid": { + val: types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("first"), + "key2": types.StringValue("second"), + }, + ), + validators: []validator.Map{ + mapvalidator.SizeAtLeast(3), + mapvalidator.SizeAtLeast(5), + }, + expected: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid Attribute Value", + "Attribute test map must contain at least 3 elements, got: 2", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid Attribute Value", + "Attribute test map must contain at least 5 elements, got: 2", + ), + }, + }, + "valid": { + val: types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("first"), + "key2": types.StringValue("second"), + }, + ), + validators: []validator.Map{ + mapvalidator.SizeAtLeast(0), + mapvalidator.SizeAtLeast(1), + }, + expected: nil, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + request := validator.MapRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + } + response := validator.MapResponse{} + mapvalidator.All(test.validators...).ValidateMap(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, test.expected); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/mapvalidator/also_requires.go b/mapvalidator/also_requires.go new file mode 100644 index 0000000..09d5686 --- /dev/null +++ b/mapvalidator/also_requires.go @@ -0,0 +1,23 @@ +package mapvalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// AlsoRequires checks that a set of path.Expression has a non-null value, +// if the current attribute or block also has a non-null value. +// +// This implements the validation logic declaratively within the schema. +// Refer to [datasourcevalidator.RequiredTogether], +// [providervalidator.RequiredTogether], or [resourcevalidator.RequiredTogether] +// for declaring this type of validation outside the schema definition. +// +// Relative path.Expression will be resolved using the attribute or block +// being validated. +func AlsoRequires(expressions ...path.Expression) validator.Map { + return schemavalidator.AlsoRequiresValidator{ + PathExpressions: expressions, + } +} diff --git a/mapvalidator/also_requires_example_test.go b/mapvalidator/also_requires_example_test.go new file mode 100644 index 0000000..a76392d --- /dev/null +++ b/mapvalidator/also_requires_example_test.go @@ -0,0 +1,30 @@ +package mapvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ExampleAlsoRequires() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.MapAttribute{ + ElementType: types.StringType, + Optional: true, + Validators: []validator.Map{ + // Validate this attribute must be configured with other_attr. + mapvalidator.AlsoRequires(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.StringAttribute{ + Optional: true, + }, + }, + } +} diff --git a/mapvalidator/any.go b/mapvalidator/any.go new file mode 100644 index 0000000..b48a306 --- /dev/null +++ b/mapvalidator/any.go @@ -0,0 +1,62 @@ +package mapvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// 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 ...validator.Map) validator.Map { + return anyValidator{ + validators: validators, + } +} + +var _ validator.Map = anyValidator{} + +// anyValidator implements the validator. +type anyValidator struct { + validators []validator.Map +} + +// 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) +} + +// ValidateMap performs the validation. +func (v anyValidator) ValidateMap(ctx context.Context, req validator.MapRequest, resp *validator.MapResponse) { + for _, subValidator := range v.validators { + validateResp := &validator.MapResponse{} + + subValidator.ValidateMap(ctx, req, validateResp) + + if !validateResp.Diagnostics.HasError() { + resp.Diagnostics = validateResp.Diagnostics + + return + } + + resp.Diagnostics.Append(validateResp.Diagnostics...) + } +} diff --git a/mapvalidator/any_example_test.go b/mapvalidator/any_example_test.go new file mode 100644 index 0000000..0b4c7dc --- /dev/null +++ b/mapvalidator/any_example_test.go @@ -0,0 +1,27 @@ +package mapvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleAny() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.MapAttribute{ + Required: true, + Validators: []validator.Map{ + // Validate this Map value must either be: + // - Between 1 and 2 elements + // - At least 4 elements + mapvalidator.Any( + mapvalidator.SizeBetween(1, 2), + mapvalidator.SizeAtLeast(4), + ), + }, + }, + }, + } +} diff --git a/mapvalidator/any_test.go b/mapvalidator/any_test.go new file mode 100644 index 0000000..3964ee7 --- /dev/null +++ b/mapvalidator/any_test.go @@ -0,0 +1,100 @@ +package mapvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/internal/testvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" +) + +func TestAnyValidatorValidateMap(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.Map + validators []validator.Map + expected diag.Diagnostics + } + tests := map[string]testCase{ + "invalid": { + val: types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("first"), + "key2": types.StringValue("second"), + }, + ), + validators: []validator.Map{ + mapvalidator.SizeAtLeast(3), + mapvalidator.SizeAtLeast(5), + }, + expected: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid Attribute Value", + "Attribute test map must contain at least 3 elements, got: 2", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid Attribute Value", + "Attribute test map must contain at least 5 elements, got: 2", + ), + }, + }, + "valid": { + val: types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("first"), + "key2": types.StringValue("second"), + }, + ), + validators: []validator.Map{ + mapvalidator.SizeAtLeast(4), + mapvalidator.SizeAtLeast(2), + }, + expected: diag.Diagnostics{}, + }, + "valid with warning": { + val: types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("first"), + "key2": types.StringValue("second"), + }, + ), + validators: []validator.Map{ + mapvalidator.All(mapvalidator.SizeAtLeast(5), testvalidator.WarningMap("failing warning summary", "failing warning details")), + mapvalidator.All(mapvalidator.SizeAtLeast(2), testvalidator.WarningMap("passing warning summary", "passing warning details")), + }, + expected: diag.Diagnostics{ + diag.NewWarningDiagnostic("passing warning summary", "passing warning details"), + }, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + request := validator.MapRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + } + response := validator.MapResponse{} + mapvalidator.Any(test.validators...).ValidateMap(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, test.expected); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/mapvalidator/any_with_all_warnings.go b/mapvalidator/any_with_all_warnings.go new file mode 100644 index 0000000..405d5c5 --- /dev/null +++ b/mapvalidator/any_with_all_warnings.go @@ -0,0 +1,64 @@ +package mapvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// 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 ...validator.Map) validator.Map { + return anyWithAllWarningsValidator{ + validators: validators, + } +} + +var _ validator.Map = anyWithAllWarningsValidator{} + +// anyWithAllWarningsValidator implements the validator. +type anyWithAllWarningsValidator struct { + validators []validator.Map +} + +// 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) +} + +// ValidateMap performs the validation. +func (v anyWithAllWarningsValidator) ValidateMap(ctx context.Context, req validator.MapRequest, resp *validator.MapResponse) { + anyValid := false + + for _, subValidator := range v.validators { + validateResp := &validator.MapResponse{} + + subValidator.ValidateMap(ctx, req, validateResp) + + if !validateResp.Diagnostics.HasError() { + anyValid = true + } + + resp.Diagnostics.Append(validateResp.Diagnostics...) + } + + if anyValid { + resp.Diagnostics = resp.Diagnostics.Warnings() + } +} diff --git a/mapvalidator/any_with_all_warnings_example_test.go b/mapvalidator/any_with_all_warnings_example_test.go new file mode 100644 index 0000000..9b641eb --- /dev/null +++ b/mapvalidator/any_with_all_warnings_example_test.go @@ -0,0 +1,27 @@ +package mapvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleAnyWithAllWarnings() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.MapAttribute{ + Required: true, + Validators: []validator.Map{ + // Validate this Map value must either be: + // - Between 1 and 2 elements + // - At least 4 elements + mapvalidator.AnyWithAllWarnings( + mapvalidator.SizeBetween(1, 2), + mapvalidator.SizeAtLeast(4), + ), + }, + }, + }, + } +} diff --git a/mapvalidator/any_with_all_warnings_test.go b/mapvalidator/any_with_all_warnings_test.go new file mode 100644 index 0000000..828313d --- /dev/null +++ b/mapvalidator/any_with_all_warnings_test.go @@ -0,0 +1,101 @@ +package mapvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/internal/testvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" +) + +func TestAnyWithAllWarningsValidatorValidateMap(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.Map + validators []validator.Map + expected diag.Diagnostics + } + tests := map[string]testCase{ + "invalid": { + val: types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("first"), + "key2": types.StringValue("second"), + }, + ), + validators: []validator.Map{ + mapvalidator.SizeAtLeast(3), + mapvalidator.SizeAtLeast(5), + }, + expected: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid Attribute Value", + "Attribute test map must contain at least 3 elements, got: 2", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid Attribute Value", + "Attribute test map must contain at least 5 elements, got: 2", + ), + }, + }, + "valid": { + val: types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("first"), + "key2": types.StringValue("second"), + }, + ), + validators: []validator.Map{ + mapvalidator.SizeAtLeast(5), + mapvalidator.SizeAtLeast(2), + }, + expected: diag.Diagnostics{}, + }, + "valid with warning": { + val: types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("first"), + "key2": types.StringValue("second"), + }, + ), + validators: []validator.Map{ + mapvalidator.All(mapvalidator.SizeAtLeast(5), testvalidator.WarningMap("failing warning summary", "failing warning details")), + mapvalidator.All(mapvalidator.SizeAtLeast(2), testvalidator.WarningMap("passing warning summary", "passing warning details")), + }, + expected: diag.Diagnostics{ + diag.NewWarningDiagnostic("failing warning summary", "failing warning details"), + diag.NewWarningDiagnostic("passing warning summary", "passing warning details"), + }, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + request := validator.MapRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + } + response := validator.MapResponse{} + mapvalidator.AnyWithAllWarnings(test.validators...).ValidateMap(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, test.expected); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/mapvalidator/at_least_one_of.go b/mapvalidator/at_least_one_of.go new file mode 100644 index 0000000..d994c2d --- /dev/null +++ b/mapvalidator/at_least_one_of.go @@ -0,0 +1,24 @@ +package mapvalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// AtLeastOneOf checks that of a set of path.Expression, +// including the attribute this validator is applied to, +// at least one has a non-null value. +// +// This implements the validation logic declaratively within the tfsdk.Schema. +// Refer to [datasourcevalidator.AtLeastOneOf], +// [providervalidator.AtLeastOneOf], or [resourcevalidator.AtLeastOneOf] +// for declaring this type of validation outside the schema definition. +// +// Any relative path.Expression will be resolved using the attribute being +// validated. +func AtLeastOneOf(expressions ...path.Expression) validator.Map { + return schemavalidator.AtLeastOneOfValidator{ + PathExpressions: expressions, + } +} diff --git a/mapvalidator/at_least_one_of_example_test.go b/mapvalidator/at_least_one_of_example_test.go new file mode 100644 index 0000000..13bd7ec --- /dev/null +++ b/mapvalidator/at_least_one_of_example_test.go @@ -0,0 +1,30 @@ +package mapvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ExampleAtLeastOneOf() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.MapAttribute{ + ElementType: types.StringType, + Optional: true, + Validators: []validator.Map{ + // Validate at least this attribute or other_attr should be configured. + mapvalidator.AtLeastOneOf(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.StringAttribute{ + Optional: true, + }, + }, + } +} diff --git a/mapvalidator/conflicts_with.go b/mapvalidator/conflicts_with.go new file mode 100644 index 0000000..123ae6e --- /dev/null +++ b/mapvalidator/conflicts_with.go @@ -0,0 +1,24 @@ +package mapvalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// ConflictsWith checks that a set of path.Expression, +// including the attribute the validator is applied to, +// do not have a value simultaneously. +// +// This implements the validation logic declaratively within the schema. +// Refer to [datasourcevalidator.Conflicting], +// [providervalidator.Conflicting], or [resourcevalidator.Conflicting] +// for declaring this type of validation outside the schema definition. +// +// Relative path.Expression will be resolved using the attribute being +// validated. +func ConflictsWith(expressions ...path.Expression) validator.Map { + return schemavalidator.ConflictsWithValidator{ + PathExpressions: expressions, + } +} diff --git a/mapvalidator/conflicts_with_example_test.go b/mapvalidator/conflicts_with_example_test.go new file mode 100644 index 0000000..e854cf3 --- /dev/null +++ b/mapvalidator/conflicts_with_example_test.go @@ -0,0 +1,30 @@ +package mapvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ExampleConflictsWith() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.MapAttribute{ + ElementType: types.StringType, + Optional: true, + Validators: []validator.Map{ + // Validate this attribute must not be configured with other_attr. + mapvalidator.ConflictsWith(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.StringAttribute{ + Optional: true, + }, + }, + } +} diff --git a/mapvalidator/exactly_one_of.go b/mapvalidator/exactly_one_of.go new file mode 100644 index 0000000..c67feda --- /dev/null +++ b/mapvalidator/exactly_one_of.go @@ -0,0 +1,25 @@ +package mapvalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// ExactlyOneOf checks that of a set of path.Expression, +// including the attribute the validator is applied to, +// one and only one attribute has a value. +// It will also cause a validation error if none are specified. +// +// This implements the validation logic declaratively within the schema. +// Refer to [datasourcevalidator.ExactlyOneOf], +// [providervalidator.ExactlyOneOf], or [resourcevalidator.ExactlyOneOf] +// for declaring this type of validation outside the schema definition. +// +// Relative path.Expression will be resolved using the attribute being +// validated. +func ExactlyOneOf(expressions ...path.Expression) validator.Map { + return schemavalidator.ExactlyOneOfValidator{ + PathExpressions: expressions, + } +} diff --git a/mapvalidator/exactly_one_of_example_test.go b/mapvalidator/exactly_one_of_example_test.go new file mode 100644 index 0000000..fcfa5be --- /dev/null +++ b/mapvalidator/exactly_one_of_example_test.go @@ -0,0 +1,30 @@ +package mapvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ExampleExactlyOneOf() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.MapAttribute{ + ElementType: types.StringType, + Optional: true, + Validators: []validator.Map{ + // Validate only this attribute or other_attr is configured. + mapvalidator.ExactlyOneOf(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.StringAttribute{ + Optional: true, + }, + }, + } +} diff --git a/mapvalidator/keys_are.go b/mapvalidator/keys_are.go index 10cb1dc..e79351f 100644 --- a/mapvalidator/keys_are.go +++ b/mapvalidator/keys_are.go @@ -5,15 +5,15 @@ import ( "fmt" "strings" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" ) -var _ tfsdk.AttributeValidator = keysAreValidator{} +var _ validator.Map = keysAreValidator{} // keysAreValidator validates that each map key validates against each of the value validators. type keysAreValidator struct { - keyValidators []tfsdk.AttributeValidator + keyValidators []validator.String } // Description describes the validation in plain text formatting. @@ -31,32 +31,37 @@ func (v keysAreValidator) MarkdownDescription(ctx context.Context) string { return v.Description(ctx) } -// Validate performs the validation. -// Note that the AttributePath specified in the ValidateAttributeRequest refers to the value in the Map with key `k`, -// whereas the AttributeConfig refers to the key itself (i.e., `k`). This is intentional as the validation being +// ValidateMap performs the validation. +// Note that the Path specified in the MapRequest refers to the value in the Map with key `k`, +// whereas the ConfigValue refers to the key itself (i.e., `k`). This is intentional as the validation being // performed is for the keys of the Map. -func (v keysAreValidator) Validate(ctx context.Context, req tfsdk.ValidateAttributeRequest, resp *tfsdk.ValidateAttributeResponse) { - elems, ok := validateMap(ctx, req, resp) - if !ok { +func (v keysAreValidator) ValidateMap(ctx context.Context, req validator.MapRequest, resp *validator.MapResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { return } - for k := range elems { - attrPath := req.AttributePath.AtMapKey(k) - request := tfsdk.ValidateAttributeRequest{ - AttributePath: attrPath, - AttributePathExpression: attrPath.Expression(), - AttributeConfig: types.StringValue(k), - Config: req.Config, + for k := range req.ConfigValue.Elements() { + attrPath := req.Path.AtMapKey(k) + validateReq := validator.StringRequest{ + Path: attrPath, + PathExpression: attrPath.Expression(), + ConfigValue: types.StringValue(k), + Config: req.Config, } - for _, validator := range v.keyValidators { - validator.Validate(ctx, request, resp) + for _, keyValidator := range v.keyValidators { + validateResp := &validator.StringResponse{} + + keyValidator.ValidateString(ctx, validateReq, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) } } } -func KeysAre(keyValidators ...tfsdk.AttributeValidator) tfsdk.AttributeValidator { +// KeysAre returns a map validator that validates all key strings with the +// given string validators. +func KeysAre(keyValidators ...validator.String) validator.Map { return keysAreValidator{ keyValidators: keyValidators, } diff --git a/mapvalidator/keys_are_example_test.go b/mapvalidator/keys_are_example_test.go index 126cada..be2957f 100644 --- a/mapvalidator/keys_are_example_test.go +++ b/mapvalidator/keys_are_example_test.go @@ -3,20 +3,19 @@ package mapvalidator_test import ( "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" ) func ExampleKeysAre() { - // Used within a GetSchema method of a DataSource, Provider, or Resource - _ = tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "example_attr": { - Required: true, - Type: types.MapType{ - ElemType: types.StringType, - }, - Validators: []tfsdk.AttributeValidator{ + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.MapAttribute{ + ElementType: types.StringType, + Required: true, + Validators: []validator.Map{ // Validate this map must contain string keys which are at least 3 characters. mapvalidator.KeysAre(stringvalidator.LengthAtLeast(3)), }, diff --git a/mapvalidator/keys_are_test.go b/mapvalidator/keys_are_test.go index c8b01f8..254b58f 100644 --- a/mapvalidator/keys_are_test.go +++ b/mapvalidator/keys_are_test.go @@ -6,10 +6,9 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" - "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" ) @@ -17,18 +16,11 @@ func TestKeysAreValidator(t *testing.T) { t.Parallel() type testCase struct { - val attr.Value - keysAreValidators []tfsdk.AttributeValidator + val types.Map + keysAreValidators []validator.String expectErrorsCount int } tests := map[string]testCase{ - "not Map": { - val: types.ListValueMust( - types.StringType, - []attr.Value{}, - ), - expectErrorsCount: 1, - }, "Map unknown": { val: types.MapUnknown( types.StringType, @@ -49,7 +41,7 @@ func TestKeysAreValidator(t *testing.T) { "two": types.StringValue("second"), }, ), - keysAreValidators: []tfsdk.AttributeValidator{ + keysAreValidators: []validator.String{ stringvalidator.LengthAtLeast(4), }, expectErrorsCount: 2, @@ -62,25 +54,12 @@ func TestKeysAreValidator(t *testing.T) { "two": types.StringValue("second"), }, ), - keysAreValidators: []tfsdk.AttributeValidator{ + keysAreValidators: []validator.String{ stringvalidator.LengthAtLeast(2), stringvalidator.LengthAtLeast(6), }, expectErrorsCount: 2, }, - "Map keys wrong type for validator": { - val: types.MapValueMust( - types.StringType, - map[string]attr.Value{ - "one": types.StringValue("first"), - "two": types.StringValue("second"), - }, - ), - keysAreValidators: []tfsdk.AttributeValidator{ - int64validator.AtLeast(6), - }, - expectErrorsCount: 1, - }, "Map keys for invalid multiple validators": { val: types.MapValueMust( types.StringType, @@ -88,7 +67,7 @@ func TestKeysAreValidator(t *testing.T) { "one": types.StringValue("first"), }, ), - keysAreValidators: []tfsdk.AttributeValidator{ + keysAreValidators: []validator.String{ stringvalidator.LengthAtLeast(5), stringvalidator.LengthAtLeast(6), }, @@ -102,7 +81,7 @@ func TestKeysAreValidator(t *testing.T) { "two": types.StringValue("second"), }, ), - keysAreValidators: []tfsdk.AttributeValidator{ + keysAreValidators: []validator.String{ stringvalidator.LengthAtLeast(3), }, expectErrorsCount: 0, @@ -112,13 +91,13 @@ func TestKeysAreValidator(t *testing.T) { for name, test := range tests { name, test := name, test t.Run(name, func(t *testing.T) { - request := tfsdk.ValidateAttributeRequest{ - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - AttributeConfig: test.val, + request := validator.MapRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, } - response := tfsdk.ValidateAttributeResponse{} - KeysAre(test.keysAreValidators...).Validate(context.TODO(), request, &response) + response := validator.MapResponse{} + KeysAre(test.keysAreValidators...).ValidateMap(context.TODO(), request, &response) if response.Diagnostics.ErrorsCount() != test.expectErrorsCount { t.Fatalf("expected %d errors, but got %d: %s", test.expectErrorsCount, response.Diagnostics.ErrorsCount(), response.Diagnostics) diff --git a/mapvalidator/size_at_least.go b/mapvalidator/size_at_least.go index 8dcb829..ade72f5 100644 --- a/mapvalidator/size_at_least.go +++ b/mapvalidator/size_at_least.go @@ -5,10 +5,10 @@ import ( "fmt" "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" ) -var _ tfsdk.AttributeValidator = sizeAtLeastValidator{} +var _ validator.Map = sizeAtLeastValidator{} // sizeAtLeastValidator validates that map contains at least min elements. type sizeAtLeastValidator struct { @@ -26,20 +26,19 @@ func (v sizeAtLeastValidator) MarkdownDescription(ctx context.Context) string { } // Validate performs the validation. -func (v sizeAtLeastValidator) Validate(ctx context.Context, req tfsdk.ValidateAttributeRequest, resp *tfsdk.ValidateAttributeResponse) { - elems, ok := validateMap(ctx, req, resp) - if !ok { +func (v sizeAtLeastValidator) ValidateMap(ctx context.Context, req validator.MapRequest, resp *validator.MapResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { return } + elems := req.ConfigValue.Elements() + if len(elems) < v.min { resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( - req.AttributePath, + req.Path, v.Description(ctx), fmt.Sprintf("%d", len(elems)), )) - - return } } @@ -50,7 +49,7 @@ func (v sizeAtLeastValidator) Validate(ctx context.Context, req tfsdk.ValidateAt // - Contains at least min elements. // // Null (unconfigured) and unknown (known after apply) values are skipped. -func SizeAtLeast(min int) tfsdk.AttributeValidator { +func SizeAtLeast(min int) validator.Map { return sizeAtLeastValidator{ min: min, } diff --git a/mapvalidator/size_at_least_example_test.go b/mapvalidator/size_at_least_example_test.go index f0eec6a..300ff05 100644 --- a/mapvalidator/size_at_least_example_test.go +++ b/mapvalidator/size_at_least_example_test.go @@ -2,20 +2,19 @@ package mapvalidator_test import ( "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" ) func ExampleSizeAtLeast() { - // Used within a GetSchema method of a DataSource, Provider, or Resource - _ = tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "example_attr": { - Required: true, - Type: types.MapType{ - ElemType: types.StringType, - }, - Validators: []tfsdk.AttributeValidator{ + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.MapAttribute{ + ElementType: types.StringType, + Required: true, + Validators: []validator.Map{ // Validate this map must contain at least 2 elements. mapvalidator.SizeAtLeast(2), }, diff --git a/mapvalidator/size_at_least_test.go b/mapvalidator/size_at_least_test.go index 3da44e9..55fbcec 100644 --- a/mapvalidator/size_at_least_test.go +++ b/mapvalidator/size_at_least_test.go @@ -6,7 +6,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" ) @@ -14,15 +14,11 @@ func TestSizeAtLeastValidator(t *testing.T) { t.Parallel() type testCase struct { - val attr.Value + val types.Map min int expectError bool } tests := map[string]testCase{ - "not a Map": { - val: types.BoolValue(true), - expectError: true, - }, "Map unknown": { val: types.MapUnknown( types.StringType, @@ -69,13 +65,13 @@ func TestSizeAtLeastValidator(t *testing.T) { for name, test := range tests { name, test := name, test t.Run(name, func(t *testing.T) { - request := tfsdk.ValidateAttributeRequest{ - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - AttributeConfig: test.val, + request := validator.MapRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, } - response := tfsdk.ValidateAttributeResponse{} - SizeAtLeast(test.min).Validate(context.TODO(), request, &response) + response := validator.MapResponse{} + SizeAtLeast(test.min).ValidateMap(context.TODO(), request, &response) if !response.Diagnostics.HasError() && test.expectError { t.Fatal("expected error, got no error") diff --git a/mapvalidator/size_at_most.go b/mapvalidator/size_at_most.go index 4de97f2..ea27643 100644 --- a/mapvalidator/size_at_most.go +++ b/mapvalidator/size_at_most.go @@ -5,10 +5,10 @@ import ( "fmt" "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" ) -var _ tfsdk.AttributeValidator = sizeAtMostValidator{} +var _ validator.Map = sizeAtMostValidator{} // sizeAtMostValidator validates that map contains at most max elements. type sizeAtMostValidator struct { @@ -26,20 +26,19 @@ func (v sizeAtMostValidator) MarkdownDescription(ctx context.Context) string { } // Validate performs the validation. -func (v sizeAtMostValidator) Validate(ctx context.Context, req tfsdk.ValidateAttributeRequest, resp *tfsdk.ValidateAttributeResponse) { - elems, ok := validateMap(ctx, req, resp) - if !ok { +func (v sizeAtMostValidator) ValidateMap(ctx context.Context, req validator.MapRequest, resp *validator.MapResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { return } + elems := req.ConfigValue.Elements() + if len(elems) > v.max { resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( - req.AttributePath, + req.Path, v.Description(ctx), fmt.Sprintf("%d", len(elems)), )) - - return } } @@ -50,7 +49,7 @@ func (v sizeAtMostValidator) Validate(ctx context.Context, req tfsdk.ValidateAtt // - Contains at most max elements. // // Null (unconfigured) and unknown (known after apply) values are skipped. -func SizeAtMost(max int) tfsdk.AttributeValidator { +func SizeAtMost(max int) validator.Map { return sizeAtMostValidator{ max: max, } diff --git a/mapvalidator/size_at_most_example_test.go b/mapvalidator/size_at_most_example_test.go index df0ed1a..ccf2f56 100644 --- a/mapvalidator/size_at_most_example_test.go +++ b/mapvalidator/size_at_most_example_test.go @@ -2,20 +2,19 @@ package mapvalidator_test import ( "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" ) func ExampleSizeAtMost() { - // Used within a GetSchema method of a DataSource, Provider, or Resource - _ = tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "example_attr": { - Required: true, - Type: types.MapType{ - ElemType: types.StringType, - }, - Validators: []tfsdk.AttributeValidator{ + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.MapAttribute{ + ElementType: types.StringType, + Required: true, + Validators: []validator.Map{ // Validate this map must contain at most 2 elements. mapvalidator.SizeAtMost(2), }, diff --git a/mapvalidator/size_at_most_test.go b/mapvalidator/size_at_most_test.go index 92e437a..8cba382 100644 --- a/mapvalidator/size_at_most_test.go +++ b/mapvalidator/size_at_most_test.go @@ -6,7 +6,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" ) @@ -14,15 +14,11 @@ func TestSizeAtMostValidator(t *testing.T) { t.Parallel() type testCase struct { - val attr.Value + val types.Map max int expectError bool } tests := map[string]testCase{ - "not a Map": { - val: types.BoolValue(true), - expectError: true, - }, "Map unknown": { val: types.MapUnknown( types.StringType, @@ -73,13 +69,13 @@ func TestSizeAtMostValidator(t *testing.T) { for name, test := range tests { name, test := name, test t.Run(name, func(t *testing.T) { - request := tfsdk.ValidateAttributeRequest{ - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - AttributeConfig: test.val, + request := validator.MapRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, } - response := tfsdk.ValidateAttributeResponse{} - SizeAtMost(test.max).Validate(context.TODO(), request, &response) + response := validator.MapResponse{} + SizeAtMost(test.max).ValidateMap(context.TODO(), request, &response) if !response.Diagnostics.HasError() && test.expectError { t.Fatal("expected error, got no error") diff --git a/mapvalidator/size_between.go b/mapvalidator/size_between.go index 0c8910f..8026f2d 100644 --- a/mapvalidator/size_between.go +++ b/mapvalidator/size_between.go @@ -5,10 +5,10 @@ import ( "fmt" "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" ) -var _ tfsdk.AttributeValidator = sizeBetweenValidator{} +var _ validator.Map = sizeBetweenValidator{} // sizeBetweenValidator validates that map contains at least min elements // and at most max elements. @@ -28,20 +28,19 @@ func (v sizeBetweenValidator) MarkdownDescription(ctx context.Context) string { } // Validate performs the validation. -func (v sizeBetweenValidator) Validate(ctx context.Context, req tfsdk.ValidateAttributeRequest, resp *tfsdk.ValidateAttributeResponse) { - elems, ok := validateMap(ctx, req, resp) - if !ok { +func (v sizeBetweenValidator) ValidateMap(ctx context.Context, req validator.MapRequest, resp *validator.MapResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { return } + elems := req.ConfigValue.Elements() + if len(elems) < v.min || len(elems) > v.max { resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( - req.AttributePath, + req.Path, v.Description(ctx), fmt.Sprintf("%d", len(elems)), )) - - return } } @@ -52,7 +51,7 @@ func (v sizeBetweenValidator) Validate(ctx context.Context, req tfsdk.ValidateAt // - Contains at least min elements and at most max elements. // // Null (unconfigured) and unknown (known after apply) values are skipped. -func SizeBetween(min, max int) tfsdk.AttributeValidator { +func SizeBetween(min, max int) validator.Map { return sizeBetweenValidator{ min: min, max: max, diff --git a/mapvalidator/size_between_example_test.go b/mapvalidator/size_between_example_test.go index ce1bf70..270cbfb 100644 --- a/mapvalidator/size_between_example_test.go +++ b/mapvalidator/size_between_example_test.go @@ -2,20 +2,19 @@ package mapvalidator_test import ( "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" ) func ExampleSizeBetween() { - // Used within a GetSchema method of a DataSource, Provider, or Resource - _ = tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "example_attr": { - Required: true, - Type: types.MapType{ - ElemType: types.StringType, - }, - Validators: []tfsdk.AttributeValidator{ + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.MapAttribute{ + ElementType: types.StringType, + Required: true, + Validators: []validator.Map{ // Validate this map must contain at least 2 and at most 4 elements. mapvalidator.SizeBetween(2, 4), }, diff --git a/mapvalidator/size_between_test.go b/mapvalidator/size_between_test.go index 2db93fa..077acda 100644 --- a/mapvalidator/size_between_test.go +++ b/mapvalidator/size_between_test.go @@ -6,7 +6,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" ) @@ -14,16 +14,12 @@ func TestSizeBetweenValidator(t *testing.T) { t.Parallel() type testCase struct { - val attr.Value + val types.Map min int max int expectError bool } tests := map[string]testCase{ - "not a Map": { - val: types.BoolValue(true), - expectError: true, - }, "Map unknown": { val: types.MapUnknown( types.StringType, @@ -112,13 +108,13 @@ func TestSizeBetweenValidator(t *testing.T) { for name, test := range tests { name, test := name, test t.Run(name, func(t *testing.T) { - request := tfsdk.ValidateAttributeRequest{ - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - AttributeConfig: test.val, + request := validator.MapRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, } - response := tfsdk.ValidateAttributeResponse{} - SizeBetween(test.min, test.max).Validate(context.TODO(), request, &response) + response := validator.MapResponse{} + SizeBetween(test.min, test.max).ValidateMap(context.TODO(), request, &response) if !response.Diagnostics.HasError() && test.expectError { t.Fatal("expected error, got no error") diff --git a/mapvalidator/type_validation.go b/mapvalidator/type_validation.go deleted file mode 100644 index b995fb9..0000000 --- a/mapvalidator/type_validation.go +++ /dev/null @@ -1,28 +0,0 @@ -package mapvalidator - -import ( - "context" - - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" -) - -// validateMap ensures that the request contains a Map value. -func validateMap(ctx context.Context, request tfsdk.ValidateAttributeRequest, response *tfsdk.ValidateAttributeResponse) (map[string]attr.Value, bool) { - var m types.Map - - diags := tfsdk.ValueAs(ctx, request.AttributeConfig, &m) - - if diags.HasError() { - response.Diagnostics.Append(diags...) - - return nil, false - } - - if m.IsUnknown() || m.IsNull() { - return nil, false - } - - return m.Elements(), true -} diff --git a/mapvalidator/type_validation_test.go b/mapvalidator/type_validation_test.go deleted file mode 100644 index 4076bc9..0000000 --- a/mapvalidator/type_validation_test.go +++ /dev/null @@ -1,86 +0,0 @@ -package mapvalidator - -import ( - "context" - "testing" - - "github.com/google/go-cmp/cmp" - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" -) - -func TestValidateMap(t *testing.T) { - t.Parallel() - - testCases := map[string]struct { - request tfsdk.ValidateAttributeRequest - expectedMap map[string]attr.Value - expectedOk bool - }{ - "invalid-type": { - request: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.BoolValue(true), - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - }, - expectedMap: nil, - expectedOk: false, - }, - "map-null": { - request: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.MapNull(types.StringType), - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - }, - expectedMap: nil, - expectedOk: false, - }, - "map-unknown": { - request: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.MapUnknown(types.StringType), - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - }, - expectedMap: nil, - expectedOk: false, - }, - "map-value": { - request: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.MapValueMust( - types.StringType, - map[string]attr.Value{ - "one": types.StringValue("first"), - "two": types.StringValue("second"), - }, - ), - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - }, - expectedMap: map[string]attr.Value{ - "one": types.StringValue("first"), - "two": types.StringValue("second"), - }, - expectedOk: true, - }, - } - - for name, testCase := range testCases { - name, testCase := name, testCase - - t.Run(name, func(t *testing.T) { - t.Parallel() - - gotMapElems, gotOk := validateMap(context.Background(), testCase.request, &tfsdk.ValidateAttributeResponse{}) - - if diff := cmp.Diff(gotMapElems, testCase.expectedMap); diff != "" { - t.Errorf("unexpected map difference: %s", diff) - } - - if diff := cmp.Diff(gotOk, testCase.expectedOk); diff != "" { - t.Errorf("unexpected ok difference: %s", diff) - } - }) - } -} diff --git a/mapvalidator/value_float64s_are.go b/mapvalidator/value_float64s_are.go new file mode 100644 index 0000000..4d50d8e --- /dev/null +++ b/mapvalidator/value_float64s_are.go @@ -0,0 +1,116 @@ +package mapvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// ValueFloat64sAre returns an validator which ensures that any configured +// Float64 values passes each Float64 validator. +// +// Null (unconfigured) and unknown (known after apply) values are skipped. +func ValueFloat64sAre(elementValidators ...validator.Float64) validator.Map { + return valueFloat64sAreValidator{ + elementValidators: elementValidators, + } +} + +var _ validator.Map = valueFloat64sAreValidator{} + +// valueFloat64sAreValidator validates that each Float64 member validates against each of the value validators. +type valueFloat64sAreValidator struct { + elementValidators []validator.Float64 +} + +// Description describes the validation in plain text formatting. +func (v valueFloat64sAreValidator) Description(ctx context.Context) string { + var descriptions []string + + for _, elementValidator := range v.elementValidators { + descriptions = append(descriptions, elementValidator.Description(ctx)) + } + + return fmt.Sprintf("element value must satisfy all validations: %s", strings.Join(descriptions, " + ")) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (v valueFloat64sAreValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +// ValidateFloat64 performs the validation. +func (v valueFloat64sAreValidator) ValidateMap(ctx context.Context, req validator.MapRequest, resp *validator.MapResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + _, ok := req.ConfigValue.ElementType(ctx).(types.Float64Typable) + + if !ok { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Validator for Element Type", + "While performing schema-based validation, an unexpected error occurred. "+ + "The attribute declares a Float64 values validator, however its values do not implement types.Float64Type or the types.Float64Typable interface for custom Float64 types. "+ + "Use the appropriate values validator that matches the element type. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Path: %s\n", req.Path.String())+ + fmt.Sprintf("Element Type: %T\n", req.ConfigValue.ElementType(ctx)), + ) + + return + } + + for key, element := range req.ConfigValue.Elements() { + elementPath := req.Path.AtMapKey(key) + + elementValuable, ok := element.(types.Float64Valuable) + + // The check above should have prevented this, but raise an error + // instead of a type assertion panic or skipping the element. Any issue + // here likely indicates something wrong in the framework itself. + if !ok { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Validator for Element Value", + "While performing schema-based validation, an unexpected error occurred. "+ + "The attribute declares a Float64 values validator, however its values do not implement types.Float64Type or the types.Float64Typable interface for custom Float64 types. "+ + "This is likely an issue with terraform-plugin-framework and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Path: %s\n", req.Path.String())+ + fmt.Sprintf("Element Type: %T\n", req.ConfigValue.ElementType(ctx))+ + fmt.Sprintf("Element Value Type: %T\n", element), + ) + + return + } + + elementValue, diags := elementValuable.ToFloat64Value(ctx) + + resp.Diagnostics.Append(diags...) + + // Only return early if the new diagnostics indicate an issue since + // it likely will be the same for all elements. + if diags.HasError() { + return + } + + elementReq := validator.Float64Request{ + Path: elementPath, + PathExpression: elementPath.Expression(), + ConfigValue: elementValue, + Config: req.Config, + } + + for _, elementValidator := range v.elementValidators { + elementResp := &validator.Float64Response{} + + elementValidator.ValidateFloat64(ctx, elementReq, elementResp) + + resp.Diagnostics.Append(elementResp.Diagnostics...) + } + } +} diff --git a/mapvalidator/value_float64s_are_example_test.go b/mapvalidator/value_float64s_are_example_test.go new file mode 100644 index 0000000..4a4049a --- /dev/null +++ b/mapvalidator/value_float64s_are_example_test.go @@ -0,0 +1,25 @@ +package mapvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/float64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ExampleValueFloat64sAre() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.MapAttribute{ + ElementType: types.Float64Type, + Required: true, + Validators: []validator.Map{ + // Validate this Map must contain Float64 values which are at least 1.2. + mapvalidator.ValueFloat64sAre(float64validator.AtLeast(1.2)), + }, + }, + }, + } +} diff --git a/mapvalidator/value_float64s_are_test.go b/mapvalidator/value_float64s_are_test.go new file mode 100644 index 0000000..352b650 --- /dev/null +++ b/mapvalidator/value_float64s_are_test.go @@ -0,0 +1,128 @@ +package mapvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/float64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" +) + +func TestValueFloat64sAreValidatorValidateMap(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + val types.Map + elementValidators []validator.Float64 + expectedDiagnostics diag.Diagnostics + }{ + "no element validators": { + val: types.MapValueMust( + types.Float64Type, + map[string]attr.Value{ + "key1": types.Float64Value(1), + "key2": types.Float64Value(2), + }, + ), + }, + "Map unknown": { + val: types.MapUnknown( + types.Float64Type, + ), + elementValidators: []validator.Float64{ + float64validator.AtLeast(1), + }, + }, + "Map null": { + val: types.MapNull( + types.Float64Type, + ), + elementValidators: []validator.Float64{ + float64validator.AtLeast(1), + }, + }, + "Map elements invalid": { + val: types.MapValueMust( + types.Float64Type, + map[string]attr.Value{ + "key1": types.Float64Value(1), + // Map ordering is random in Go, avoid multiple keys + }, + ), + elementValidators: []validator.Float64{ + float64validator.AtLeast(3), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtMapKey("key1"), + "Invalid Attribute Value", + "Attribute test[\"key1\"] value must be at least 3.000000, got: 1.000000", + ), + }, + }, + "Map elements invalid for multiple validator": { + val: types.MapValueMust( + types.Float64Type, + map[string]attr.Value{ + "key1": types.Float64Value(1), + // Map ordering is random in Go, avoid multiple keys + }, + ), + elementValidators: []validator.Float64{ + float64validator.AtLeast(3), + float64validator.AtLeast(4), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtMapKey("key1"), + "Invalid Attribute Value", + "Attribute test[\"key1\"] value must be at least 3.000000, got: 1.000000", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtMapKey("key1"), + "Invalid Attribute Value", + "Attribute test[\"key1\"] value must be at least 4.000000, got: 1.000000", + ), + }, + }, + "Map elements valid": { + val: types.MapValueMust( + types.Float64Type, + map[string]attr.Value{ + "key1": types.Float64Value(1), + "key2": types.Float64Value(2), + }, + ), + elementValidators: []validator.Float64{ + float64validator.AtLeast(1), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + request := validator.MapRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: testCase.val, + } + response := validator.MapResponse{} + mapvalidator.ValueFloat64sAre(testCase.elementValidators...).ValidateMap(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, testCase.expectedDiagnostics); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/mapvalidator/value_int64s_are.go b/mapvalidator/value_int64s_are.go new file mode 100644 index 0000000..5825be7 --- /dev/null +++ b/mapvalidator/value_int64s_are.go @@ -0,0 +1,116 @@ +package mapvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// ValueInt64sAre returns an validator which ensures that any configured +// Int64 values passes each Int64 validator. +// +// Null (unconfigured) and unknown (known after apply) values are skipped. +func ValueInt64sAre(elementValidators ...validator.Int64) validator.Map { + return valueInt64sAreValidator{ + elementValidators: elementValidators, + } +} + +var _ validator.Map = valueInt64sAreValidator{} + +// valueInt64sAreValidator validates that each Int64 member validates against each of the value validators. +type valueInt64sAreValidator struct { + elementValidators []validator.Int64 +} + +// Description describes the validation in plain text formatting. +func (v valueInt64sAreValidator) Description(ctx context.Context) string { + var descriptions []string + + for _, elementValidator := range v.elementValidators { + descriptions = append(descriptions, elementValidator.Description(ctx)) + } + + return fmt.Sprintf("element value must satisfy all validations: %s", strings.Join(descriptions, " + ")) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (v valueInt64sAreValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +// ValidateInt64 performs the validation. +func (v valueInt64sAreValidator) ValidateMap(ctx context.Context, req validator.MapRequest, resp *validator.MapResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + _, ok := req.ConfigValue.ElementType(ctx).(types.Int64Typable) + + if !ok { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Validator for Element Type", + "While performing schema-based validation, an unexpected error occurred. "+ + "The attribute declares a Int64 values validator, however its values do not implement types.Int64Type or the types.Int64Typable interface for custom Int64 types. "+ + "Use the appropriate values validator that matches the element type. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Path: %s\n", req.Path.String())+ + fmt.Sprintf("Element Type: %T\n", req.ConfigValue.ElementType(ctx)), + ) + + return + } + + for key, element := range req.ConfigValue.Elements() { + elementPath := req.Path.AtMapKey(key) + + elementValuable, ok := element.(types.Int64Valuable) + + // The check above should have prevented this, but raise an error + // instead of a type assertion panic or skipping the element. Any issue + // here likely indicates something wrong in the framework itself. + if !ok { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Validator for Element Value", + "While performing schema-based validation, an unexpected error occurred. "+ + "The attribute declares a Int64 values validator, however its values do not implement types.Int64Type or the types.Int64Typable interface for custom Int64 types. "+ + "This is likely an issue with terraform-plugin-framework and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Path: %s\n", req.Path.String())+ + fmt.Sprintf("Element Type: %T\n", req.ConfigValue.ElementType(ctx))+ + fmt.Sprintf("Element Value Type: %T\n", element), + ) + + return + } + + elementValue, diags := elementValuable.ToInt64Value(ctx) + + resp.Diagnostics.Append(diags...) + + // Only return early if the new diagnostics indicate an issue since + // it likely will be the same for all elements. + if diags.HasError() { + return + } + + elementReq := validator.Int64Request{ + Path: elementPath, + PathExpression: elementPath.Expression(), + ConfigValue: elementValue, + Config: req.Config, + } + + for _, elementValidator := range v.elementValidators { + elementResp := &validator.Int64Response{} + + elementValidator.ValidateInt64(ctx, elementReq, elementResp) + + resp.Diagnostics.Append(elementResp.Diagnostics...) + } + } +} diff --git a/mapvalidator/value_int64s_are_example_test.go b/mapvalidator/value_int64s_are_example_test.go new file mode 100644 index 0000000..366e3f4 --- /dev/null +++ b/mapvalidator/value_int64s_are_example_test.go @@ -0,0 +1,25 @@ +package mapvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ExampleValueInt64sAre() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.MapAttribute{ + ElementType: types.Int64Type, + Required: true, + Validators: []validator.Map{ + // Validate this Map must contain Int64 values which are at least 1. + mapvalidator.ValueInt64sAre(int64validator.AtLeast(1)), + }, + }, + }, + } +} diff --git a/mapvalidator/value_int64s_are_test.go b/mapvalidator/value_int64s_are_test.go new file mode 100644 index 0000000..6668216 --- /dev/null +++ b/mapvalidator/value_int64s_are_test.go @@ -0,0 +1,128 @@ +package mapvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" +) + +func TestValueInt64sAreValidatorValidateMap(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + val types.Map + elementValidators []validator.Int64 + expectedDiagnostics diag.Diagnostics + }{ + "no element validators": { + val: types.MapValueMust( + types.Int64Type, + map[string]attr.Value{ + "key1": types.Int64Value(1), + "key2": types.Int64Value(2), + }, + ), + }, + "Map unknown": { + val: types.MapUnknown( + types.Int64Type, + ), + elementValidators: []validator.Int64{ + int64validator.AtLeast(1), + }, + }, + "Map null": { + val: types.MapNull( + types.Int64Type, + ), + elementValidators: []validator.Int64{ + int64validator.AtLeast(1), + }, + }, + "Map elements invalid": { + val: types.MapValueMust( + types.Int64Type, + map[string]attr.Value{ + "key1": types.Int64Value(1), + // Map ordering is random in Go, avoid multiple keys + }, + ), + elementValidators: []validator.Int64{ + int64validator.AtLeast(3), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtMapKey("key1"), + "Invalid Attribute Value", + "Attribute test[\"key1\"] value must be at least 3, got: 1", + ), + }, + }, + "Map elements invalid for multiple validator": { + val: types.MapValueMust( + types.Int64Type, + map[string]attr.Value{ + "key1": types.Int64Value(1), + // Map ordering is random in Go, avoid multiple keys + }, + ), + elementValidators: []validator.Int64{ + int64validator.AtLeast(3), + int64validator.AtLeast(4), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtMapKey("key1"), + "Invalid Attribute Value", + "Attribute test[\"key1\"] value must be at least 3, got: 1", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtMapKey("key1"), + "Invalid Attribute Value", + "Attribute test[\"key1\"] value must be at least 4, got: 1", + ), + }, + }, + "Map elements valid": { + val: types.MapValueMust( + types.Int64Type, + map[string]attr.Value{ + "key1": types.Int64Value(1), + "key2": types.Int64Value(2), + }, + ), + elementValidators: []validator.Int64{ + int64validator.AtLeast(1), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + request := validator.MapRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: testCase.val, + } + response := validator.MapResponse{} + mapvalidator.ValueInt64sAre(testCase.elementValidators...).ValidateMap(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, testCase.expectedDiagnostics); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/mapvalidator/value_lists_are.go b/mapvalidator/value_lists_are.go new file mode 100644 index 0000000..3750e28 --- /dev/null +++ b/mapvalidator/value_lists_are.go @@ -0,0 +1,116 @@ +package mapvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// ValueListsAre returns an validator which ensures that any configured +// List values passes each List validator. +// +// Null (unconfigured) and unknown (known after apply) values are skipped. +func ValueListsAre(elementValidators ...validator.List) validator.Map { + return valueListsAreValidator{ + elementValidators: elementValidators, + } +} + +var _ validator.Map = valueListsAreValidator{} + +// valueListsAreValidator validates that each List member validates against each of the value validators. +type valueListsAreValidator struct { + elementValidators []validator.List +} + +// Description describes the validation in plain text formatting. +func (v valueListsAreValidator) Description(ctx context.Context) string { + var descriptions []string + + for _, elementValidator := range v.elementValidators { + descriptions = append(descriptions, elementValidator.Description(ctx)) + } + + return fmt.Sprintf("element value must satisfy all validations: %s", strings.Join(descriptions, " + ")) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (v valueListsAreValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +// ValidateList performs the validation. +func (v valueListsAreValidator) ValidateMap(ctx context.Context, req validator.MapRequest, resp *validator.MapResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + _, ok := req.ConfigValue.ElementType(ctx).(types.ListTypable) + + if !ok { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Validator for Element Type", + "While performing schema-based validation, an unexpected error occurred. "+ + "The attribute declares a List values validator, however its values do not implement types.ListType or the types.ListTypable interface for custom List types. "+ + "Use the appropriate values validator that matches the element type. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Path: %s\n", req.Path.String())+ + fmt.Sprintf("Element Type: %T\n", req.ConfigValue.ElementType(ctx)), + ) + + return + } + + for key, element := range req.ConfigValue.Elements() { + elementPath := req.Path.AtMapKey(key) + + elementValuable, ok := element.(types.ListValuable) + + // The check above should have prevented this, but raise an error + // instead of a type assertion panic or skipping the element. Any issue + // here likely indicates something wrong in the framework itself. + if !ok { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Validator for Element Value", + "While performing schema-based validation, an unexpected error occurred. "+ + "The attribute declares a List values validator, however its values do not implement types.ListType or the types.ListTypable interface for custom List types. "+ + "This is likely an issue with terraform-plugin-framework and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Path: %s\n", req.Path.String())+ + fmt.Sprintf("Element Type: %T\n", req.ConfigValue.ElementType(ctx))+ + fmt.Sprintf("Element Value Type: %T\n", element), + ) + + return + } + + elementValue, diags := elementValuable.ToListValue(ctx) + + resp.Diagnostics.Append(diags...) + + // Only return early if the new diagnostics indicate an issue since + // it likely will be the same for all elements. + if diags.HasError() { + return + } + + elementReq := validator.ListRequest{ + Path: elementPath, + PathExpression: elementPath.Expression(), + ConfigValue: elementValue, + Config: req.Config, + } + + for _, elementValidator := range v.elementValidators { + elementResp := &validator.ListResponse{} + + elementValidator.ValidateList(ctx, elementReq, elementResp) + + resp.Diagnostics.Append(elementResp.Diagnostics...) + } + } +} diff --git a/mapvalidator/value_lists_are_example_test.go b/mapvalidator/value_lists_are_example_test.go new file mode 100644 index 0000000..33ac855 --- /dev/null +++ b/mapvalidator/value_lists_are_example_test.go @@ -0,0 +1,30 @@ +package mapvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ExampleValueListsAre() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.MapAttribute{ + // This Map has values of Lists of Strings. + // Roughly equivalent to map[string][]string. + ElementType: types.ListType{ + ElemType: types.StringType, + }, + Required: true, + Validators: []validator.Map{ + // Validate this Map must contain List elements + // which have at least 1 String element. + mapvalidator.ValueListsAre(listvalidator.SizeAtLeast(1)), + }, + }, + }, + } +} diff --git a/mapvalidator/value_lists_are_test.go b/mapvalidator/value_lists_are_test.go new file mode 100644 index 0000000..0099e7f --- /dev/null +++ b/mapvalidator/value_lists_are_test.go @@ -0,0 +1,164 @@ +package mapvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" +) + +func TestValueListsAreValidatorValidateMap(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + val types.Map + elementValidators []validator.List + expectedDiagnostics diag.Diagnostics + }{ + "no element validators": { + val: types.MapValueMust( + types.ListType{ElemType: types.StringType}, + map[string]attr.Value{ + "key1": types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + "key2": types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("third"), + types.StringValue("fourth"), + }, + ), + }, + ), + }, + "Map unknown": { + val: types.MapUnknown( + types.StringType, + ), + elementValidators: []validator.List{ + listvalidator.SizeAtLeast(1), + }, + }, + "Map null": { + val: types.MapNull( + types.StringType, + ), + elementValidators: []validator.List{ + listvalidator.SizeAtLeast(1), + }, + }, + "Map elements invalid": { + val: types.MapValueMust( + types.ListType{ElemType: types.StringType}, + map[string]attr.Value{ + "key1": types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + // Map ordering is random in Go, avoid multiple keys + }, + ), + elementValidators: []validator.List{ + listvalidator.SizeAtLeast(3), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtMapKey("key1"), + "Invalid Attribute Value", + "Attribute test[\"key1\"] list must contain at least 3 elements, got: 2", + ), + }, + }, + "Map elements invalid for multiple validator": { + val: types.MapValueMust( + types.ListType{ElemType: types.StringType}, + map[string]attr.Value{ + "key1": types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + // Map ordering is random in Go, avoid multiple keys + }, + ), + elementValidators: []validator.List{ + listvalidator.SizeAtLeast(3), + listvalidator.SizeAtLeast(4), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtMapKey("key1"), + "Invalid Attribute Value", + "Attribute test[\"key1\"] list must contain at least 3 elements, got: 2", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtMapKey("key1"), + "Invalid Attribute Value", + "Attribute test[\"key1\"] list must contain at least 4 elements, got: 2", + ), + }, + }, + "Map elements valid": { + val: types.MapValueMust( + types.ListType{ElemType: types.StringType}, + map[string]attr.Value{ + "key1": types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + "key2": types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("third"), + types.StringValue("fourth"), + }, + ), + }, + ), + elementValidators: []validator.List{ + listvalidator.SizeAtLeast(1), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + request := validator.MapRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: testCase.val, + } + response := validator.MapResponse{} + mapvalidator.ValueListsAre(testCase.elementValidators...).ValidateMap(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, testCase.expectedDiagnostics); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/mapvalidator/value_maps_are.go b/mapvalidator/value_maps_are.go new file mode 100644 index 0000000..1bf7481 --- /dev/null +++ b/mapvalidator/value_maps_are.go @@ -0,0 +1,116 @@ +package mapvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// ValueMapsAre returns an validator which ensures that any configured +// Map values passes each Map validator. +// +// Null (unconfigured) and unknown (known after apply) values are skipped. +func ValueMapsAre(elementValidators ...validator.Map) validator.Map { + return valueMapsAreValidator{ + elementValidators: elementValidators, + } +} + +var _ validator.Map = valueMapsAreValidator{} + +// valueMapsAreValidator validates that each Map member validates against each of the value validators. +type valueMapsAreValidator struct { + elementValidators []validator.Map +} + +// Description describes the validation in plain text formatting. +func (v valueMapsAreValidator) Description(ctx context.Context) string { + var descriptions []string + + for _, elementValidator := range v.elementValidators { + descriptions = append(descriptions, elementValidator.Description(ctx)) + } + + return fmt.Sprintf("element value must satisfy all validations: %s", strings.Join(descriptions, " + ")) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (v valueMapsAreValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +// ValidateMap performs the validation. +func (v valueMapsAreValidator) ValidateMap(ctx context.Context, req validator.MapRequest, resp *validator.MapResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + _, ok := req.ConfigValue.ElementType(ctx).(types.MapTypable) + + if !ok { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Validator for Element Type", + "While performing schema-based validation, an unexpected error occurred. "+ + "The attribute declares a Map values validator, however its values do not implement types.MapType or the types.MapTypable interface for custom Map types. "+ + "Use the appropriate values validator that matches the element type. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Path: %s\n", req.Path.String())+ + fmt.Sprintf("Element Type: %T\n", req.ConfigValue.ElementType(ctx)), + ) + + return + } + + for key, element := range req.ConfigValue.Elements() { + elementPath := req.Path.AtMapKey(key) + + elementValuable, ok := element.(types.MapValuable) + + // The check above should have prevented this, but raise an error + // instead of a type assertion panic or skipping the element. Any issue + // here likely indicates something wrong in the framework itself. + if !ok { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Validator for Element Value", + "While performing schema-based validation, an unexpected error occurred. "+ + "The attribute declares a Map values validator, however its values do not implement types.MapType or the types.MapTypable interface for custom Map types. "+ + "This is likely an issue with terraform-plugin-framework and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Path: %s\n", req.Path.String())+ + fmt.Sprintf("Element Type: %T\n", req.ConfigValue.ElementType(ctx))+ + fmt.Sprintf("Element Value Type: %T\n", element), + ) + + return + } + + elementValue, diags := elementValuable.ToMapValue(ctx) + + resp.Diagnostics.Append(diags...) + + // Only return early if the new diagnostics indicate an issue since + // it likely will be the same for all elements. + if diags.HasError() { + return + } + + elementReq := validator.MapRequest{ + Path: elementPath, + PathExpression: elementPath.Expression(), + ConfigValue: elementValue, + Config: req.Config, + } + + for _, elementValidator := range v.elementValidators { + elementResp := &validator.MapResponse{} + + elementValidator.ValidateMap(ctx, elementReq, elementResp) + + resp.Diagnostics.Append(elementResp.Diagnostics...) + } + } +} diff --git a/mapvalidator/value_maps_are_example_test.go b/mapvalidator/value_maps_are_example_test.go new file mode 100644 index 0000000..a541e6d --- /dev/null +++ b/mapvalidator/value_maps_are_example_test.go @@ -0,0 +1,29 @@ +package mapvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ExampleValueMapsAre() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.MapAttribute{ + // This Map has values of Maps of Strings. + // Roughly equivalent to map[string]map[string]string. + ElementType: types.MapType{ + ElemType: types.StringType, + }, + Required: true, + Validators: []validator.Map{ + // Validate this Map must contain Map elements + // which have at least 1 element. + mapvalidator.ValueMapsAre(mapvalidator.SizeAtLeast(1)), + }, + }, + }, + } +} diff --git a/mapvalidator/value_maps_are_test.go b/mapvalidator/value_maps_are_test.go new file mode 100644 index 0000000..bea2207 --- /dev/null +++ b/mapvalidator/value_maps_are_test.go @@ -0,0 +1,163 @@ +package mapvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" +) + +func TestValueMapsAreValidatorValidateMap(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + val types.Map + elementValidators []validator.Map + expectedDiagnostics diag.Diagnostics + }{ + "no element validators": { + val: types.MapValueMust( + types.MapType{ElemType: types.StringType}, + map[string]attr.Value{ + "key1": types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("first"), + "key2": types.StringValue("second"), + }, + ), + "key2": types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("third"), + "key2": types.StringValue("fourth"), + }, + ), + }, + ), + }, + "Map unknown": { + val: types.MapUnknown( + types.StringType, + ), + elementValidators: []validator.Map{ + mapvalidator.SizeAtLeast(1), + }, + }, + "Map null": { + val: types.MapNull( + types.StringType, + ), + elementValidators: []validator.Map{ + mapvalidator.SizeAtLeast(1), + }, + }, + "Map elements invalid": { + val: types.MapValueMust( + types.MapType{ElemType: types.StringType}, + map[string]attr.Value{ + "key1": types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("first"), + // Map ordering is random in Go, avoid multiple keys + }, + ), + // Map ordering is random in Go, avoid multiple keys + }, + ), + elementValidators: []validator.Map{ + mapvalidator.SizeAtLeast(3), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtMapKey("key1"), + "Invalid Attribute Value", + "Attribute test[\"key1\"] map must contain at least 3 elements, got: 1", + ), + }, + }, + "Map elements invalid for multiple validator": { + val: types.MapValueMust( + types.MapType{ElemType: types.StringType}, + map[string]attr.Value{ + "key1": types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("first"), + // Map ordering is random in Go, avoid multiple keys + }, + ), + // Map ordering is random in Go, avoid multiple keys + }, + ), + elementValidators: []validator.Map{ + mapvalidator.SizeAtLeast(3), + mapvalidator.SizeAtLeast(4), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtMapKey("key1"), + "Invalid Attribute Value", + "Attribute test[\"key1\"] map must contain at least 3 elements, got: 1", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtMapKey("key1"), + "Invalid Attribute Value", + "Attribute test[\"key1\"] map must contain at least 4 elements, got: 1", + ), + }, + }, + "Map elements valid": { + val: types.MapValueMust( + types.MapType{ElemType: types.StringType}, + map[string]attr.Value{ + "key1": types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("first"), + "key2": types.StringValue("second"), + }, + ), + "key2": types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("third"), + "key2": types.StringValue("fourth"), + }, + ), + }, + ), + elementValidators: []validator.Map{ + mapvalidator.SizeAtLeast(1), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + request := validator.MapRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: testCase.val, + } + response := validator.MapResponse{} + mapvalidator.ValueMapsAre(testCase.elementValidators...).ValidateMap(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, testCase.expectedDiagnostics); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/mapvalidator/value_numbers_are.go b/mapvalidator/value_numbers_are.go new file mode 100644 index 0000000..eaf13b3 --- /dev/null +++ b/mapvalidator/value_numbers_are.go @@ -0,0 +1,116 @@ +package mapvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// ValueNumbersAre returns an validator which ensures that any configured +// Number values passes each Number validator. +// +// Null (unconfigured) and unknown (known after apply) values are skipped. +func ValueNumbersAre(elementValidators ...validator.Number) validator.Map { + return valueNumbersAreValidator{ + elementValidators: elementValidators, + } +} + +var _ validator.Map = valueNumbersAreValidator{} + +// valueNumbersAreValidator validates that each Number member validates against each of the value validators. +type valueNumbersAreValidator struct { + elementValidators []validator.Number +} + +// Description describes the validation in plain text formatting. +func (v valueNumbersAreValidator) Description(ctx context.Context) string { + var descriptions []string + + for _, elementValidator := range v.elementValidators { + descriptions = append(descriptions, elementValidator.Description(ctx)) + } + + return fmt.Sprintf("element value must satisfy all validations: %s", strings.Join(descriptions, " + ")) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (v valueNumbersAreValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +// ValidateNumber performs the validation. +func (v valueNumbersAreValidator) ValidateMap(ctx context.Context, req validator.MapRequest, resp *validator.MapResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + _, ok := req.ConfigValue.ElementType(ctx).(types.NumberTypable) + + if !ok { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Validator for Element Type", + "While performing schema-based validation, an unexpected error occurred. "+ + "The attribute declares a Number values validator, however its values do not implement types.NumberType or the types.NumberTypable interface for custom Number types. "+ + "Use the appropriate values validator that matches the element type. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Path: %s\n", req.Path.String())+ + fmt.Sprintf("Element Type: %T\n", req.ConfigValue.ElementType(ctx)), + ) + + return + } + + for key, element := range req.ConfigValue.Elements() { + elementPath := req.Path.AtMapKey(key) + + elementValuable, ok := element.(types.NumberValuable) + + // The check above should have prevented this, but raise an error + // instead of a type assertion panic or skipping the element. Any issue + // here likely indicates something wrong in the framework itself. + if !ok { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Validator for Element Value", + "While performing schema-based validation, an unexpected error occurred. "+ + "The attribute declares a Number values validator, however its values do not implement types.NumberType or the types.NumberTypable interface for custom Number types. "+ + "This is likely an issue with terraform-plugin-framework and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Path: %s\n", req.Path.String())+ + fmt.Sprintf("Element Type: %T\n", req.ConfigValue.ElementType(ctx))+ + fmt.Sprintf("Element Value Type: %T\n", element), + ) + + return + } + + elementValue, diags := elementValuable.ToNumberValue(ctx) + + resp.Diagnostics.Append(diags...) + + // Only return early if the new diagnostics indicate an issue since + // it likely will be the same for all elements. + if diags.HasError() { + return + } + + elementReq := validator.NumberRequest{ + Path: elementPath, + PathExpression: elementPath.Expression(), + ConfigValue: elementValue, + Config: req.Config, + } + + for _, elementValidator := range v.elementValidators { + elementResp := &validator.NumberResponse{} + + elementValidator.ValidateNumber(ctx, elementReq, elementResp) + + resp.Diagnostics.Append(elementResp.Diagnostics...) + } + } +} diff --git a/mapvalidator/value_numbers_are_example_test.go b/mapvalidator/value_numbers_are_example_test.go new file mode 100644 index 0000000..156b611 --- /dev/null +++ b/mapvalidator/value_numbers_are_example_test.go @@ -0,0 +1,32 @@ +package mapvalidator_test + +import ( + "math/big" + + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/numbervalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ExampleValueNumbersAre() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.MapAttribute{ + ElementType: types.NumberType, + Required: true, + Validators: []validator.Map{ + // Validate this Map must contain Number values which are 1.2 or 2.4. + mapvalidator.ValueNumbersAre( + numbervalidator.OneOf( + big.NewFloat(1.2), + big.NewFloat(2.4), + ), + ), + }, + }, + }, + } +} diff --git a/mapvalidator/value_numbers_are_test.go b/mapvalidator/value_numbers_are_test.go new file mode 100644 index 0000000..17479cd --- /dev/null +++ b/mapvalidator/value_numbers_are_test.go @@ -0,0 +1,129 @@ +package mapvalidator_test + +import ( + "context" + "math/big" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/numbervalidator" +) + +func TestValueNumbersAreValidatorValidateMap(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + val types.Map + elementValidators []validator.Number + expectedDiagnostics diag.Diagnostics + }{ + "no element validators": { + val: types.MapValueMust( + types.NumberType, + map[string]attr.Value{ + "key1": types.NumberValue(big.NewFloat(1.2)), + "key2": types.NumberValue(big.NewFloat(2.4)), + }, + ), + }, + "Map unknown": { + val: types.MapUnknown( + types.NumberType, + ), + elementValidators: []validator.Number{ + numbervalidator.OneOf(big.NewFloat(1.2)), + }, + }, + "Map null": { + val: types.MapNull( + types.NumberType, + ), + elementValidators: []validator.Number{ + numbervalidator.OneOf(big.NewFloat(1.2)), + }, + }, + "Map elements invalid": { + val: types.MapValueMust( + types.NumberType, + map[string]attr.Value{ + "key1": types.NumberValue(big.NewFloat(1.2)), + // Map ordering is random in Go, avoid multiple keys + }, + ), + elementValidators: []validator.Number{ + numbervalidator.OneOf(big.NewFloat(3.6)), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtMapKey("key1"), + "Invalid Attribute Value Match", + "Attribute test[\"key1\"] value must be one of: [\"3.6\"], got: 1.2", + ), + }, + }, + "Map elements invalid for multiple validator": { + val: types.MapValueMust( + types.NumberType, + map[string]attr.Value{ + "key1": types.NumberValue(big.NewFloat(1.2)), + // Map ordering is random in Go, avoid multiple keys + }, + ), + elementValidators: []validator.Number{ + numbervalidator.OneOf(big.NewFloat(3.6)), + numbervalidator.OneOf(big.NewFloat(4.8)), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtMapKey("key1"), + "Invalid Attribute Value Match", + "Attribute test[\"key1\"] value must be one of: [\"3.6\"], got: 1.2", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtMapKey("key1"), + "Invalid Attribute Value Match", + "Attribute test[\"key1\"] value must be one of: [\"4.8\"], got: 1.2", + ), + }, + }, + "Map elements valid": { + val: types.MapValueMust( + types.NumberType, + map[string]attr.Value{ + "key1": types.NumberValue(big.NewFloat(1.2)), + "key2": types.NumberValue(big.NewFloat(2.4)), + }, + ), + elementValidators: []validator.Number{ + numbervalidator.OneOf(big.NewFloat(1.2), big.NewFloat(2.4)), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + request := validator.MapRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: testCase.val, + } + response := validator.MapResponse{} + mapvalidator.ValueNumbersAre(testCase.elementValidators...).ValidateMap(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, testCase.expectedDiagnostics); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/mapvalidator/value_sets_are.go b/mapvalidator/value_sets_are.go new file mode 100644 index 0000000..7c86e86 --- /dev/null +++ b/mapvalidator/value_sets_are.go @@ -0,0 +1,116 @@ +package mapvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// ValueSetsAre returns an validator which ensures that any configured +// Set values passes each Set validator. +// +// Null (unconfigured) and unknown (known after apply) values are skipped. +func ValueSetsAre(elementValidators ...validator.Set) validator.Map { + return valueSetsAreValidator{ + elementValidators: elementValidators, + } +} + +var _ validator.Map = valueSetsAreValidator{} + +// valueSetsAreValidator validates that each set member validates against each of the value validators. +type valueSetsAreValidator struct { + elementValidators []validator.Set +} + +// Description describes the validation in plain text formatting. +func (v valueSetsAreValidator) Description(ctx context.Context) string { + var descriptions []string + + for _, elementValidator := range v.elementValidators { + descriptions = append(descriptions, elementValidator.Description(ctx)) + } + + return fmt.Sprintf("element value must satisfy all validations: %s", strings.Join(descriptions, " + ")) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (v valueSetsAreValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +// ValidateSet performs the validation. +func (v valueSetsAreValidator) ValidateMap(ctx context.Context, req validator.MapRequest, resp *validator.MapResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + _, ok := req.ConfigValue.ElementType(ctx).(types.SetTypable) + + if !ok { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Validator for Element Type", + "While performing schema-based validation, an unexpected error occurred. "+ + "The attribute declares a Set values validator, however its values do not implement types.SetType or the types.SetTypable interface for custom Set types. "+ + "Use the appropriate values validator that matches the element type. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Path: %s\n", req.Path.String())+ + fmt.Sprintf("Element Type: %T\n", req.ConfigValue.ElementType(ctx)), + ) + + return + } + + for key, element := range req.ConfigValue.Elements() { + elementPath := req.Path.AtMapKey(key) + + elementValuable, ok := element.(types.SetValuable) + + // The check above should have prevented this, but raise an error + // instead of a type assertion panic or skipping the element. Any issue + // here likely indicates something wrong in the framework itself. + if !ok { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Validator for Element Value", + "While performing schema-based validation, an unexpected error occurred. "+ + "The attribute declares a Set values validator, however its values do not implement types.SetType or the types.SetTypable interface for custom Set types. "+ + "This is likely an issue with terraform-plugin-framework and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Path: %s\n", req.Path.String())+ + fmt.Sprintf("Element Type: %T\n", req.ConfigValue.ElementType(ctx))+ + fmt.Sprintf("Element Value Type: %T\n", element), + ) + + return + } + + elementValue, diags := elementValuable.ToSetValue(ctx) + + resp.Diagnostics.Append(diags...) + + // Only return early if the new diagnostics indicate an issue since + // it likely will be the same for all elements. + if diags.HasError() { + return + } + + elementReq := validator.SetRequest{ + Path: elementPath, + PathExpression: elementPath.Expression(), + ConfigValue: elementValue, + Config: req.Config, + } + + for _, elementValidator := range v.elementValidators { + elementResp := &validator.SetResponse{} + + elementValidator.ValidateSet(ctx, elementReq, elementResp) + + resp.Diagnostics.Append(elementResp.Diagnostics...) + } + } +} diff --git a/mapvalidator/value_sets_are_example_test.go b/mapvalidator/value_sets_are_example_test.go new file mode 100644 index 0000000..b6d8e9e --- /dev/null +++ b/mapvalidator/value_sets_are_example_test.go @@ -0,0 +1,30 @@ +package mapvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ExampleValueSetsAre() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.MapAttribute{ + // This Map has values of Sets of Strings. + // Roughly equivalent to map[string][]string. + ElementType: types.SetType{ + ElemType: types.StringType, + }, + Required: true, + Validators: []validator.Map{ + // Validate this Map must contain Set elements + // which have at least 1 String element. + mapvalidator.ValueSetsAre(setvalidator.SizeAtLeast(1)), + }, + }, + }, + } +} diff --git a/mapvalidator/value_sets_are_test.go b/mapvalidator/value_sets_are_test.go new file mode 100644 index 0000000..6801cca --- /dev/null +++ b/mapvalidator/value_sets_are_test.go @@ -0,0 +1,164 @@ +package mapvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" +) + +func TestValueSetsAreValidatorValidateMap(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + val types.Map + elementValidators []validator.Set + expectedDiagnostics diag.Diagnostics + }{ + "no element validators": { + val: types.MapValueMust( + types.SetType{ElemType: types.StringType}, + map[string]attr.Value{ + "key1": types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + "key2": types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("third"), + types.StringValue("fourth"), + }, + ), + }, + ), + }, + "Map unknown": { + val: types.MapUnknown( + types.StringType, + ), + elementValidators: []validator.Set{ + setvalidator.SizeAtLeast(1), + }, + }, + "Map null": { + val: types.MapNull( + types.StringType, + ), + elementValidators: []validator.Set{ + setvalidator.SizeAtLeast(1), + }, + }, + "Map elements invalid": { + val: types.MapValueMust( + types.SetType{ElemType: types.StringType}, + map[string]attr.Value{ + "key1": types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + // Map ordering is random in Go, avoid multiple keys + }, + ), + elementValidators: []validator.Set{ + setvalidator.SizeAtLeast(3), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtMapKey("key1"), + "Invalid Attribute Value", + "Attribute test[\"key1\"] set must contain at least 3 elements, got: 2", + ), + }, + }, + "Map elements invalid for multiple validator": { + val: types.MapValueMust( + types.SetType{ElemType: types.StringType}, + map[string]attr.Value{ + "key1": types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + // Map ordering is random in Go, avoid multiple keys + }, + ), + elementValidators: []validator.Set{ + setvalidator.SizeAtLeast(3), + setvalidator.SizeAtLeast(4), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtMapKey("key1"), + "Invalid Attribute Value", + "Attribute test[\"key1\"] set must contain at least 3 elements, got: 2", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtMapKey("key1"), + "Invalid Attribute Value", + "Attribute test[\"key1\"] set must contain at least 4 elements, got: 2", + ), + }, + }, + "Map elements valid": { + val: types.MapValueMust( + types.SetType{ElemType: types.StringType}, + map[string]attr.Value{ + "key1": types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + "key2": types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("third"), + types.StringValue("fourth"), + }, + ), + }, + ), + elementValidators: []validator.Set{ + setvalidator.SizeAtLeast(1), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + request := validator.MapRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: testCase.val, + } + response := validator.MapResponse{} + mapvalidator.ValueSetsAre(testCase.elementValidators...).ValidateMap(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, testCase.expectedDiagnostics); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/mapvalidator/value_strings_are.go b/mapvalidator/value_strings_are.go new file mode 100644 index 0000000..7ab1c04 --- /dev/null +++ b/mapvalidator/value_strings_are.go @@ -0,0 +1,116 @@ +package mapvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// ValueStringsAre returns an validator which ensures that any configured +// String values passes each String validator. +// +// Null (unconfigured) and unknown (known after apply) values are skipped. +func ValueStringsAre(elementValidators ...validator.String) validator.Map { + return valueStringsAreValidator{ + elementValidators: elementValidators, + } +} + +var _ validator.Map = valueStringsAreValidator{} + +// valueStringsAreValidator validates that each Map member validates against each of the value validators. +type valueStringsAreValidator struct { + elementValidators []validator.String +} + +// Description describes the validation in plain text formatting. +func (v valueStringsAreValidator) Description(ctx context.Context) string { + var descriptions []string + + for _, elementValidator := range v.elementValidators { + descriptions = append(descriptions, elementValidator.Description(ctx)) + } + + return fmt.Sprintf("element value must satisfy all validations: %s", strings.Join(descriptions, " + ")) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (v valueStringsAreValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +// ValidateMap performs the validation. +func (v valueStringsAreValidator) ValidateMap(ctx context.Context, req validator.MapRequest, resp *validator.MapResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + _, ok := req.ConfigValue.ElementType(ctx).(types.StringTypable) + + if !ok { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Validator for Element Type", + "While performing schema-based validation, an unexpected error occurred. "+ + "The attribute declares a String values validator, however its values do not implement types.StringType or the types.StringTypable interface for custom String types. "+ + "Use the appropriate values validator that matches the element type. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Path: %s\n", req.Path.String())+ + fmt.Sprintf("Element Type: %T\n", req.ConfigValue.ElementType(ctx)), + ) + + return + } + + for key, element := range req.ConfigValue.Elements() { + elementPath := req.Path.AtMapKey(key) + + elementValuable, ok := element.(types.StringValuable) + + // The check above should have prevented this, but raise an error + // instead of a type assertion panic or skipping the element. Any issue + // here likely indicates something wrong in the framework itself. + if !ok { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Validator for Element Value", + "While performing schema-based validation, an unexpected error occurred. "+ + "The attribute declares a String values validator, however its values do not implement types.StringType or the types.StringTypable interface for custom String types. "+ + "This is likely an issue with terraform-plugin-framework and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Path: %s\n", req.Path.String())+ + fmt.Sprintf("Element Type: %T\n", req.ConfigValue.ElementType(ctx))+ + fmt.Sprintf("Element Value Type: %T\n", element), + ) + + return + } + + elementValue, diags := elementValuable.ToStringValue(ctx) + + resp.Diagnostics.Append(diags...) + + // Only return early if the new diagnostics indicate an issue since + // it likely will be the same for all elements. + if diags.HasError() { + return + } + + elementReq := validator.StringRequest{ + Path: elementPath, + PathExpression: elementPath.Expression(), + ConfigValue: elementValue, + Config: req.Config, + } + + for _, elementValidator := range v.elementValidators { + elementResp := &validator.StringResponse{} + + elementValidator.ValidateString(ctx, elementReq, elementResp) + + resp.Diagnostics.Append(elementResp.Diagnostics...) + } + } +} diff --git a/mapvalidator/value_strings_are_example_test.go b/mapvalidator/value_strings_are_example_test.go new file mode 100644 index 0000000..cce3c20 --- /dev/null +++ b/mapvalidator/value_strings_are_example_test.go @@ -0,0 +1,25 @@ +package mapvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ExampleValueStringsAre() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.MapAttribute{ + ElementType: types.StringType, + Required: true, + Validators: []validator.Map{ + // Validate this Map must contain string values which are at least 3 characters. + mapvalidator.ValueStringsAre(stringvalidator.LengthAtLeast(3)), + }, + }, + }, + } +} diff --git a/mapvalidator/value_strings_are_test.go b/mapvalidator/value_strings_are_test.go new file mode 100644 index 0000000..adacf88 --- /dev/null +++ b/mapvalidator/value_strings_are_test.go @@ -0,0 +1,128 @@ +package mapvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" +) + +func TestValueStringsAreValidatorValidateMap(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + val types.Map + elementValidators []validator.String + expectedDiagnostics diag.Diagnostics + }{ + "no element validators": { + val: types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("first"), + "key2": types.StringValue("second"), + }, + ), + }, + "Map unknown": { + val: types.MapUnknown( + types.StringType, + ), + elementValidators: []validator.String{ + stringvalidator.LengthAtLeast(6), + }, + }, + "Map null": { + val: types.MapNull( + types.StringType, + ), + elementValidators: []validator.String{ + stringvalidator.LengthAtLeast(6), + }, + }, + "Map elements invalid": { + val: types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("first"), + // Map ordering is random in Go, avoid multiple keys + }, + ), + elementValidators: []validator.String{ + stringvalidator.LengthAtLeast(7), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtMapKey("key1"), + "Invalid Attribute Value Length", + "Attribute test[\"key1\"] string length must be at least 7, got: 5", + ), + }, + }, + "Map elements invalid for multiple validator": { + val: types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("first"), + // Map ordering is random in Go, avoid multiple keys + }, + ), + elementValidators: []validator.String{ + stringvalidator.LengthAtLeast(7), + stringvalidator.LengthAtLeast(8), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtMapKey("key1"), + "Invalid Attribute Value Length", + "Attribute test[\"key1\"] string length must be at least 7, got: 5", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtMapKey("key1"), + "Invalid Attribute Value Length", + "Attribute test[\"key1\"] string length must be at least 8, got: 5", + ), + }, + }, + "Map elements valid": { + val: types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("first"), + "key2": types.StringValue("second"), + }, + ), + elementValidators: []validator.String{ + stringvalidator.LengthAtLeast(5), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + request := validator.MapRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: testCase.val, + } + response := validator.MapResponse{} + mapvalidator.ValueStringsAre(testCase.elementValidators...).ValidateMap(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, testCase.expectedDiagnostics); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/mapvalidator/values_are.go b/mapvalidator/values_are.go deleted file mode 100644 index 172f1c0..0000000 --- a/mapvalidator/values_are.go +++ /dev/null @@ -1,66 +0,0 @@ -package mapvalidator - -import ( - "context" - "fmt" - "strings" - - "github.com/hashicorp/terraform-plugin-framework/tfsdk" -) - -var _ tfsdk.AttributeValidator = valuesAreValidator{} - -// valuesAreValidator validates that each map value validates against each of the value validators. -type valuesAreValidator struct { - valueValidators []tfsdk.AttributeValidator -} - -// Description describes the validation in plain text formatting. -func (v valuesAreValidator) Description(ctx context.Context) string { - var descriptions []string - for _, validator := range v.valueValidators { - descriptions = append(descriptions, validator.Description(ctx)) - } - - return fmt.Sprintf("value must satisfy all validations: %s", strings.Join(descriptions, " + ")) -} - -// MarkdownDescription describes the validation in Markdown formatting. -func (v valuesAreValidator) MarkdownDescription(ctx context.Context) string { - return v.Description(ctx) -} - -// Validate performs the validation. -func (v valuesAreValidator) Validate(ctx context.Context, req tfsdk.ValidateAttributeRequest, resp *tfsdk.ValidateAttributeResponse) { - elems, ok := validateMap(ctx, req, resp) - if !ok { - return - } - - for k, elem := range elems { - attrPath := req.AttributePath.AtMapKey(k) - request := tfsdk.ValidateAttributeRequest{ - AttributePath: attrPath, - AttributePathExpression: attrPath.Expression(), - AttributeConfig: elem, - Config: req.Config, - } - - for _, validator := range v.valueValidators { - validator.Validate(ctx, request, resp) - } - } -} - -// ValuesAre returns an AttributeValidator which ensures that any configured -// attribute value: -// -// - Is a Map. -// - Contains Map elements, each of which validate against each value validator. -// -// Null (unconfigured) and unknown (known after apply) values are skipped. -func ValuesAre(valueValidators ...tfsdk.AttributeValidator) tfsdk.AttributeValidator { - return valuesAreValidator{ - valueValidators: valueValidators, - } -} diff --git a/mapvalidator/values_are_example_test.go b/mapvalidator/values_are_example_test.go deleted file mode 100644 index bd6c9af..0000000 --- a/mapvalidator/values_are_example_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package mapvalidator_test - -import ( - "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" - "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" -) - -func ExampleValuesAre() { - // Used within a GetSchema method of a DataSource, Provider, or Resource - _ = tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "example_attr": { - Required: true, - Type: types.MapType{ - ElemType: types.StringType, - }, - Validators: []tfsdk.AttributeValidator{ - // Validate this map must contain string values which are at least 3 characters. - mapvalidator.ValuesAre(stringvalidator.LengthAtLeast(3)), - }, - }, - }, - } -} diff --git a/mapvalidator/values_are_test.go b/mapvalidator/values_are_test.go deleted file mode 100644 index 614087b..0000000 --- a/mapvalidator/values_are_test.go +++ /dev/null @@ -1,112 +0,0 @@ -package mapvalidator - -import ( - "context" - "testing" - - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" - - "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" - "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" -) - -func TestValuesAreValidator(t *testing.T) { - t.Parallel() - - type testCase struct { - val attr.Value - valuesAreValidators []tfsdk.AttributeValidator - expectError bool - } - tests := map[string]testCase{ - "Map unknown": { - val: types.MapUnknown( - types.StringType, - ), - expectError: false, - }, - "Map null": { - val: types.MapNull( - types.StringType, - ), - expectError: false, - }, - "Map value invalid": { - val: types.MapValueMust( - types.StringType, - map[string]attr.Value{ - "number_one": types.StringValue("first"), - "number_two": types.StringValue("second"), - }, - ), - valuesAreValidators: []tfsdk.AttributeValidator{ - stringvalidator.LengthAtLeast(6), - }, - expectError: true, - }, - "Maps value invalid for second validator": { - val: types.MapValueMust( - types.StringType, - map[string]attr.Value{ - "number_one": types.StringValue("first"), - "number_two": types.StringValue("second"), - }, - ), - valuesAreValidators: []tfsdk.AttributeValidator{ - stringvalidator.LengthAtLeast(2), - stringvalidator.LengthAtLeast(6), - }, - expectError: true, - }, - "Map values wrong type for validator": { - val: types.MapValueMust( - types.StringType, - map[string]attr.Value{ - "number_one": types.StringValue("first"), - "number_two": types.StringValue("second"), - }, - ), - valuesAreValidators: []tfsdk.AttributeValidator{ - int64validator.AtLeast(6), - }, - expectError: true, - }, - "Map values valid": { - val: types.MapValueMust( - types.StringType, - map[string]attr.Value{ - "one": types.StringValue("first"), - "two": types.StringValue("second"), - }, - ), - valuesAreValidators: []tfsdk.AttributeValidator{ - stringvalidator.LengthAtLeast(5), - }, - expectError: false, - }, - } - - for name, test := range tests { - name, test := name, test - t.Run(name, func(t *testing.T) { - request := tfsdk.ValidateAttributeRequest{ - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - AttributeConfig: test.val, - } - response := tfsdk.ValidateAttributeResponse{} - ValuesAre(test.valuesAreValidators...).Validate(context.TODO(), request, &response) - - if !response.Diagnostics.HasError() && test.expectError { - t.Fatal("expected error, got no error") - } - - if response.Diagnostics.HasError() && !test.expectError { - t.Fatalf("got unexpected error: %s", response.Diagnostics) - } - }) - } -} diff --git a/metavalidator/all.go b/metavalidator/all.go deleted file mode 100644 index 2c3484d..0000000 --- a/metavalidator/all.go +++ /dev/null @@ -1,53 +0,0 @@ -package metavalidator - -import ( - "context" - "fmt" - "strings" - - "github.com/hashicorp/terraform-plugin-framework/tfsdk" -) - -var _ tfsdk.AttributeValidator = allValidator{} - -// allValidator validates that value validates against all the value validators and is for use in -// conjunction with the anyValidator or anyWithAllWarningsValidator, as the default behaviour is -// to validate all at the top-level. -type allValidator struct { - valueValidators []tfsdk.AttributeValidator -} - -// Description describes the validation in plain text formatting. -func (v allValidator) Description(ctx context.Context) string { - var descriptions []string - for _, validator := range v.valueValidators { - descriptions = append(descriptions, validator.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) -} - -// Validate performs the validation. -func (v allValidator) Validate(ctx context.Context, req tfsdk.ValidateAttributeRequest, resp *tfsdk.ValidateAttributeResponse) { - for _, validator := range v.valueValidators { - validator.Validate(ctx, req, resp) - } -} - -// All returns an AttributeValidator which ensures that any configured -// attribute value: -// -// - Validates against all the value validators. -// -// Use of All is only necessary when used in conjunction with Any or AnyWithAllWarnings -// as the []tfsdk.AttributeValidator field automatically applies a logical AND. -func All(valueValidators ...tfsdk.AttributeValidator) tfsdk.AttributeValidator { - return allValidator{ - valueValidators: valueValidators, - } -} diff --git a/metavalidator/all_example_test.go b/metavalidator/all_example_test.go deleted file mode 100644 index af14bb8..0000000 --- a/metavalidator/all_example_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package metavalidator_test - -import ( - "regexp" - - "github.com/hashicorp/terraform-plugin-framework-validators/metavalidator" - "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" -) - -func ExampleAll() { - // Used within a GetSchema method of a DataSource, Provider, or Resource - _ = tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "example_attr": { - Required: true, - Type: types.StringType, - Validators: []tfsdk.AttributeValidator{ - // Validate this string value must either be: - // - "!!!" - // - At least 3 alphanumeric characters. - metavalidator.Any( - stringvalidator.OneOf("!!!"), - metavalidator.All( - stringvalidator.LengthAtLeast(3), - stringvalidator.RegexMatches( - regexp.MustCompile(`^[a-zA-Z0-9]*$`), - "must contain only alphanumeric characters", - ), - ), - ), - }, - }, - }, - } -} diff --git a/metavalidator/all_test.go b/metavalidator/all_test.go deleted file mode 100644 index b50d9c5..0000000 --- a/metavalidator/all_test.go +++ /dev/null @@ -1,93 +0,0 @@ -package metavalidator_test - -import ( - "context" - "testing" - - "github.com/google/go-cmp/cmp" - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/diag" - "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" - - "github.com/hashicorp/terraform-plugin-framework-validators/metavalidator" - "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" -) - -func TestAllValidator(t *testing.T) { - t.Parallel() - - type testCase struct { - val attr.Value - valueValidators []tfsdk.AttributeValidator - expectError bool - expectedValidatorDiags diag.Diagnostics - } - tests := map[string]testCase{ - "Type mismatch": { - val: types.Int64Value(12), - valueValidators: []tfsdk.AttributeValidator{ - stringvalidator.LengthAtLeast(3), - stringvalidator.LengthAtLeast(5), - }, - expectError: true, - expectedValidatorDiags: diag.Diagnostics{ - diag.NewAttributeErrorDiagnostic( - path.Root("test"), - "Invalid Attribute Type", - "Attribute test expected value of type string, got: types.Int64Type", - ), - }, - }, - "String invalid": { - val: types.StringValue("one"), - valueValidators: []tfsdk.AttributeValidator{ - stringvalidator.LengthAtLeast(3), - stringvalidator.LengthAtLeast(5), - }, - expectError: true, - expectedValidatorDiags: diag.Diagnostics{ - diag.NewAttributeErrorDiagnostic( - path.Root("test"), - "Invalid Attribute Value Length", - "Attribute test string length must be at least 5, got: 3", - ), - }, - }, - "String valid": { - val: types.StringValue("one"), - valueValidators: []tfsdk.AttributeValidator{ - stringvalidator.LengthAtLeast(2), - stringvalidator.LengthAtLeast(3), - }, - expectError: false, - expectedValidatorDiags: nil, - }, - } - - for name, test := range tests { - name, test := name, test - t.Run(name, func(t *testing.T) { - request := tfsdk.ValidateAttributeRequest{ - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - AttributeConfig: test.val, - } - response := tfsdk.ValidateAttributeResponse{} - metavalidator.All(test.valueValidators...).Validate(context.TODO(), request, &response) - - if !response.Diagnostics.HasError() && test.expectError { - t.Fatal("expected error, got no error") - } - - if response.Diagnostics.HasError() && !test.expectError { - t.Fatalf("got unexpected error: %s", response.Diagnostics) - } - - if diff := cmp.Diff(response.Diagnostics, test.expectedValidatorDiags); diff != "" { - t.Errorf("unexpected diags difference: %s", diff) - } - }) - } -} diff --git a/metavalidator/any.go b/metavalidator/any.go deleted file mode 100644 index c18ba52..0000000 --- a/metavalidator/any.go +++ /dev/null @@ -1,68 +0,0 @@ -package metavalidator - -import ( - "context" - "fmt" - "strings" - - "github.com/hashicorp/terraform-plugin-framework/diag" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" -) - -var _ tfsdk.AttributeValidator = anyValidator{} - -// anyValidator validates that value validates against at least one of the value validators. -type anyValidator struct { - valueValidators []tfsdk.AttributeValidator -} - -// Description describes the validation in plain text formatting. -func (v anyValidator) Description(ctx context.Context) string { - var descriptions []string - for _, validator := range v.valueValidators { - descriptions = append(descriptions, validator.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) -} - -// Validate performs the validation. -// The validator will pass if it encounters a value validator that returns no errors and will then return any warnings -// from the passing validator. Using All validator as value validators will pass if all the validators supplied in an -// All validator pass. -func (v anyValidator) Validate(ctx context.Context, req tfsdk.ValidateAttributeRequest, resp *tfsdk.ValidateAttributeResponse) { - for _, validator := range v.valueValidators { - validatorResp := &tfsdk.ValidateAttributeResponse{ - Diagnostics: diag.Diagnostics{}, - } - - validator.Validate(ctx, req, validatorResp) - - if !validatorResp.Diagnostics.HasError() { - resp.Diagnostics = validatorResp.Diagnostics - return - } - - resp.Diagnostics.Append(validatorResp.Diagnostics...) - } -} - -// Any returns an AttributeValidator which ensures that any configured -// attribute value: -// -// - Validates against at least one of the value 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(valueValidators ...tfsdk.AttributeValidator) tfsdk.AttributeValidator { - return anyValidator{ - valueValidators: valueValidators, - } -} diff --git a/metavalidator/any_example_test.go b/metavalidator/any_example_test.go deleted file mode 100644 index db216c4..0000000 --- a/metavalidator/any_example_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package metavalidator_test - -import ( - "regexp" - - "github.com/hashicorp/terraform-plugin-framework-validators/metavalidator" - "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" -) - -func ExampleAny() { - // Used within a GetSchema method of a DataSource, Provider, or Resource - _ = tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "example_attr": { - Required: true, - Type: types.StringType, - Validators: []tfsdk.AttributeValidator{ - // Validate this string value must either be: - // - "!!!" - // - Any length of alphanumeric characters. - metavalidator.Any( - stringvalidator.OneOf("!!!"), - stringvalidator.RegexMatches( - regexp.MustCompile(`^[a-zA-Z0-9]*$`), - "must contain only alphanumeric characters", - ), - ), - }, - }, - }, - } -} diff --git a/metavalidator/any_test.go b/metavalidator/any_test.go deleted file mode 100644 index e249110..0000000 --- a/metavalidator/any_test.go +++ /dev/null @@ -1,165 +0,0 @@ -package metavalidator_test - -import ( - "context" - "testing" - - "github.com/google/go-cmp/cmp" - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/diag" - "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" - - "github.com/hashicorp/terraform-plugin-framework-validators/metavalidator" - "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" -) - -func TestAnyValidator(t *testing.T) { - t.Parallel() - - type testCase struct { - val attr.Value - valueValidators []tfsdk.AttributeValidator - expectError bool - expectedValidatorDiags diag.Diagnostics - } - tests := map[string]testCase{ - "Type mismatch": { - val: types.Int64Value(12), - valueValidators: []tfsdk.AttributeValidator{ - stringvalidator.LengthAtLeast(3), - stringvalidator.LengthAtLeast(5), - }, - expectError: true, - expectedValidatorDiags: diag.Diagnostics{ - diag.NewAttributeErrorDiagnostic( - path.Root("test"), - "Invalid Attribute Type", - "Attribute test expected value of type string, got: types.Int64Type", - ), - }, - }, - "String invalid": { - val: types.StringValue("one"), - valueValidators: []tfsdk.AttributeValidator{ - stringvalidator.LengthAtLeast(4), - stringvalidator.LengthAtLeast(5), - }, - expectError: true, - expectedValidatorDiags: diag.Diagnostics{ - diag.NewAttributeErrorDiagnostic( - path.Root("test"), - "Invalid Attribute Value Length", - "Attribute test string length must be at least 4, got: 3", - ), - diag.NewAttributeErrorDiagnostic( - path.Root("test"), - "Invalid Attribute Value Length", - "Attribute test string length must be at least 5, got: 3", - ), - }, - }, - "String valid": { - val: types.StringValue("one"), - valueValidators: []tfsdk.AttributeValidator{ - stringvalidator.LengthAtLeast(5), - stringvalidator.LengthAtLeast(3), - }, - expectError: false, - expectedValidatorDiags: diag.Diagnostics{}, - }, - "String invalid in all nested validators": { - val: types.StringValue("one"), - valueValidators: []tfsdk.AttributeValidator{ - metavalidator.All(stringvalidator.LengthAtLeast(6), stringvalidator.LengthAtLeast(3)), - metavalidator.All(stringvalidator.LengthAtLeast(5), stringvalidator.LengthAtLeast(3)), - }, - expectError: true, - expectedValidatorDiags: diag.Diagnostics{ - diag.NewAttributeErrorDiagnostic( - path.Root("test"), - "Invalid Attribute Value Length", - "Attribute test string length must be at least 6, got: 3", - ), - diag.NewAttributeErrorDiagnostic( - path.Root("test"), - "Invalid Attribute Value Length", - "Attribute test string length must be at least 5, got: 3", - ), - }, - }, - "String valid in one of the nested validators": { - val: types.StringValue("one"), - valueValidators: []tfsdk.AttributeValidator{ - metavalidator.All(stringvalidator.LengthAtLeast(6), stringvalidator.LengthAtLeast(3)), - metavalidator.All(stringvalidator.LengthAtLeast(2), stringvalidator.LengthAtLeast(3)), - }, - expectError: false, - expectedValidatorDiags: diag.Diagnostics{}, - }, - "String valid in one of the nested validators with warning": { - val: types.StringValue("one"), - valueValidators: []tfsdk.AttributeValidator{ - metavalidator.All(stringvalidator.LengthAtLeast(6), stringvalidator.LengthAtLeast(3)), - metavalidator.All(stringvalidator.LengthAtLeast(2), warningValidator{ - summary: "Warning", - detail: "Warning", - }), - }, - expectError: false, - expectedValidatorDiags: diag.Diagnostics{ - diag.NewWarningDiagnostic("Warning", "Warning")}, - }, - "String valid in one of the nested validators with warning and warnings from failed validations": { - val: types.StringValue("one"), - valueValidators: []tfsdk.AttributeValidator{ - metavalidator.All(stringvalidator.LengthAtLeast(6), warningValidator{ - summary: "Warning", - detail: "Warning from first failed validation", - }), - metavalidator.All(stringvalidator.LengthAtLeast(2), warningValidator{ - summary: "Warning", - detail: "Warning from first successful validation", - }), - metavalidator.All(stringvalidator.LengthAtLeast(10), warningValidator{ - summary: "Warning", - detail: "Warning from second failed validation", - }), - metavalidator.All(stringvalidator.LengthAtLeast(2), warningValidator{ - summary: "Warning", - detail: "Warning from second successful validation", - }), - }, - expectError: false, - expectedValidatorDiags: diag.Diagnostics{ - diag.NewWarningDiagnostic("Warning", "Warning from first successful validation"), - }, - }, - } - - for name, test := range tests { - name, test := name, test - t.Run(name, func(t *testing.T) { - request := tfsdk.ValidateAttributeRequest{ - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - AttributeConfig: test.val, - } - response := tfsdk.ValidateAttributeResponse{} - metavalidator.Any(test.valueValidators...).Validate(context.TODO(), request, &response) - - if !response.Diagnostics.HasError() && test.expectError { - t.Fatal("expected error, got no error") - } - - if response.Diagnostics.HasError() && !test.expectError { - t.Fatalf("got unexpected error: %s", response.Diagnostics) - } - - if diff := cmp.Diff(response.Diagnostics, test.expectedValidatorDiags); diff != "" { - t.Errorf("unexpected diags difference: %s", diff) - } - }) - } -} diff --git a/metavalidator/any_with_all_warnings.go b/metavalidator/any_with_all_warnings.go deleted file mode 100644 index 3a69397..0000000 --- a/metavalidator/any_with_all_warnings.go +++ /dev/null @@ -1,71 +0,0 @@ -package metavalidator - -import ( - "context" - "fmt" - "strings" - - "github.com/hashicorp/terraform-plugin-framework/diag" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" -) - -var _ tfsdk.AttributeValidator = anyWithAllWarningsValidator{} - -// anyWithAllWarningsValidator validates that value validates against at least one of the value validators. -type anyWithAllWarningsValidator struct { - valueValidators []tfsdk.AttributeValidator -} - -// Description describes the validation in plain text formatting. -func (v anyWithAllWarningsValidator) Description(ctx context.Context) string { - var descriptions []string - for _, validator := range v.valueValidators { - descriptions = append(descriptions, validator.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) -} - -// Validate performs the validation. -// The validator will pass if it encounters a value validator that returns no errors and, will then return all -// accumulated warning diagnostics from the passing validator(s) and any failing validator(s). -// Using All validator as value validators will pass if all the validators supplied in an All validator pass. -func (v anyWithAllWarningsValidator) Validate(ctx context.Context, req tfsdk.ValidateAttributeRequest, resp *tfsdk.ValidateAttributeResponse) { - anyValid := false - - for _, validator := range v.valueValidators { - validatorResp := &tfsdk.ValidateAttributeResponse{ - Diagnostics: diag.Diagnostics{}, - } - - validator.Validate(ctx, req, validatorResp) - - if !validatorResp.Diagnostics.HasError() { - anyValid = true - } - - resp.Diagnostics.Append(validatorResp.Diagnostics...) - } - - if anyValid { - resp.Diagnostics = resp.Diagnostics.Warnings() - } -} - -// AnyWithAllWarnings returns an AttributeValidator which ensures that any configured -// attribute value: -// -// - Validates against at least one of the value validators. -// - Returns all warnings for all passing and failing validators when at least one of the validators passes. -// -// Use Any() to only return warnings from the passing validator. -func AnyWithAllWarnings(valueValidators ...tfsdk.AttributeValidator) tfsdk.AttributeValidator { - return anyWithAllWarningsValidator{ - valueValidators: valueValidators, - } -} diff --git a/metavalidator/any_with_all_warnings_example_test.go b/metavalidator/any_with_all_warnings_example_test.go deleted file mode 100644 index 7e223b5..0000000 --- a/metavalidator/any_with_all_warnings_example_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package metavalidator_test - -import ( - "regexp" - - "github.com/hashicorp/terraform-plugin-framework-validators/metavalidator" - "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" -) - -func ExampleAnyWithAllWarnings() { - // Used within a GetSchema method of a DataSource, Provider, or Resource - _ = tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "example_attr": { - Required: true, - Type: types.StringType, - Validators: []tfsdk.AttributeValidator{ - // Validate this string value must either be: - // - "!!!" - // - Any length of alphanumeric characters. - metavalidator.AnyWithAllWarnings( - stringvalidator.OneOf("!!!"), - stringvalidator.RegexMatches( - regexp.MustCompile(`^[a-zA-Z0-9]*$`), - "must contain only alphanumeric characters", - ), - ), - }, - }, - }, - } -} diff --git a/metavalidator/any_with_all_warnings_test.go b/metavalidator/any_with_all_warnings_test.go deleted file mode 100644 index 88683c8..0000000 --- a/metavalidator/any_with_all_warnings_test.go +++ /dev/null @@ -1,187 +0,0 @@ -package metavalidator_test - -import ( - "context" - "testing" - - "github.com/google/go-cmp/cmp" - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/diag" - "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" - - "github.com/hashicorp/terraform-plugin-framework-validators/metavalidator" - "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" -) - -var _ tfsdk.AttributeValidator = warningValidator{} - -type warningValidator struct { - summary string - detail string -} - -func (validator warningValidator) Description(_ context.Context) string { - return "description" -} - -func (validator warningValidator) MarkdownDescription(ctx context.Context) string { - return validator.Description(ctx) -} - -func (validator warningValidator) Validate(ctx context.Context, request tfsdk.ValidateAttributeRequest, response *tfsdk.ValidateAttributeResponse) { - response.Diagnostics.Append(diag.NewWarningDiagnostic(validator.summary, validator.detail)) -} - -func TestAnyWithAllWarningsValidator(t *testing.T) { - t.Parallel() - - type testCase struct { - val attr.Value - valueValidators []tfsdk.AttributeValidator - expectError bool - expectedValidatorDiags diag.Diagnostics - } - tests := map[string]testCase{ - "Type mismatch": { - val: types.Int64Value(12), - valueValidators: []tfsdk.AttributeValidator{ - stringvalidator.LengthAtLeast(3), - stringvalidator.LengthAtLeast(5), - }, - expectError: true, - expectedValidatorDiags: diag.Diagnostics{ - diag.NewAttributeErrorDiagnostic( - path.Root("test"), - "Invalid Attribute Type", - "Attribute test expected value of type string, got: types.Int64Type", - ), - }, - }, - "String invalid": { - val: types.StringValue("one"), - valueValidators: []tfsdk.AttributeValidator{ - stringvalidator.LengthAtLeast(4), - stringvalidator.LengthAtLeast(5), - }, - expectError: true, - expectedValidatorDiags: diag.Diagnostics{ - diag.NewAttributeErrorDiagnostic( - path.Root("test"), - "Invalid Attribute Value Length", - "Attribute test string length must be at least 4, got: 3", - ), - diag.NewAttributeErrorDiagnostic( - path.Root("test"), - "Invalid Attribute Value Length", - "Attribute test string length must be at least 5, got: 3", - ), - }, - }, - "String valid": { - val: types.StringValue("one"), - valueValidators: []tfsdk.AttributeValidator{ - stringvalidator.LengthAtLeast(5), - stringvalidator.LengthAtLeast(3), - }, - expectError: false, - expectedValidatorDiags: diag.Diagnostics{}, - }, - "String invalid in all nested validators": { - val: types.StringValue("one"), - valueValidators: []tfsdk.AttributeValidator{ - metavalidator.All(stringvalidator.LengthAtLeast(6), stringvalidator.LengthAtLeast(3)), - metavalidator.All(stringvalidator.LengthAtLeast(5), stringvalidator.LengthAtLeast(3)), - }, - expectError: true, - expectedValidatorDiags: diag.Diagnostics{ - diag.NewAttributeErrorDiagnostic( - path.Root("test"), - "Invalid Attribute Value Length", - "Attribute test string length must be at least 6, got: 3", - ), - diag.NewAttributeErrorDiagnostic( - path.Root("test"), - "Invalid Attribute Value Length", - "Attribute test string length must be at least 5, got: 3", - ), - }, - }, - "String valid in one of the nested validators": { - val: types.StringValue("one"), - valueValidators: []tfsdk.AttributeValidator{ - metavalidator.All(stringvalidator.LengthAtLeast(6), stringvalidator.LengthAtLeast(3)), - metavalidator.All(stringvalidator.LengthAtLeast(2), stringvalidator.LengthAtLeast(3)), - }, - expectError: false, - expectedValidatorDiags: diag.Diagnostics{}, - }, - "String valid in one of the nested validators with warning": { - val: types.StringValue("one"), - valueValidators: []tfsdk.AttributeValidator{ - metavalidator.All(stringvalidator.LengthAtLeast(6), stringvalidator.LengthAtLeast(3)), - metavalidator.All(stringvalidator.LengthAtLeast(2), warningValidator{ - summary: "Warning", - detail: "Warning", - }), - }, - expectError: false, - expectedValidatorDiags: diag.Diagnostics{ - diag.NewWarningDiagnostic("Warning", "Warning")}, - }, - "String valid in one of the nested validators with warning and warnings from failed validations": { - val: types.StringValue("one"), - valueValidators: []tfsdk.AttributeValidator{ - metavalidator.All(stringvalidator.LengthAtLeast(6), warningValidator{ - summary: "Warning", - detail: "Warning from first failed validation", - }), - metavalidator.All(stringvalidator.LengthAtLeast(2), warningValidator{ - summary: "Warning", - detail: "Warning from first successful validation", - }), - metavalidator.All(stringvalidator.LengthAtLeast(10), warningValidator{ - summary: "Warning", - detail: "Warning from second failed validation", - }), - metavalidator.All(stringvalidator.LengthAtLeast(2), warningValidator{ - summary: "Warning", - detail: "Warning from second successful validation", - }), - }, - expectError: false, - expectedValidatorDiags: diag.Diagnostics{ - diag.NewWarningDiagnostic("Warning", "Warning from first failed validation"), - diag.NewWarningDiagnostic("Warning", "Warning from first successful validation"), - diag.NewWarningDiagnostic("Warning", "Warning from second failed validation"), - diag.NewWarningDiagnostic("Warning", "Warning from second successful validation"), - }, - }, - } - - for name, test := range tests { - name, test := name, test - t.Run(name, func(t *testing.T) { - request := tfsdk.ValidateAttributeRequest{ - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - AttributeConfig: test.val, - } - response := tfsdk.ValidateAttributeResponse{} - metavalidator.AnyWithAllWarnings(test.valueValidators...).Validate(context.TODO(), request, &response) - - if !response.Diagnostics.HasError() && test.expectError { - t.Fatal("expected error, got no error") - } - - if response.Diagnostics.HasError() && !test.expectError { - t.Fatalf("got unexpected error: %s", response.Diagnostics) - } - - if diff := cmp.Diff(response.Diagnostics, test.expectedValidatorDiags); diff != "" { - t.Errorf("unexpected diags difference: %s", diff) - } - }) - } -} diff --git a/metavalidator/doc.go b/metavalidator/doc.go deleted file mode 100644 index 88a36b3..0000000 --- a/metavalidator/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package metavalidator provides attribute validators that combine (algebraically) other attribute validators. -package metavalidator diff --git a/numbervalidator/all.go b/numbervalidator/all.go new file mode 100644 index 0000000..0536521 --- /dev/null +++ b/numbervalidator/all.go @@ -0,0 +1,54 @@ +package numbervalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// All returns a validator which ensures that any configured attribute value +// 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 ...validator.Number) validator.Number { + return allValidator{ + validators: validators, + } +} + +var _ validator.Number = allValidator{} + +// allValidator implements the validator. +type allValidator struct { + validators []validator.Number +} + +// 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) +} + +// ValidateNumber performs the validation. +func (v allValidator) ValidateNumber(ctx context.Context, req validator.NumberRequest, resp *validator.NumberResponse) { + for _, subValidator := range v.validators { + validateResp := &validator.NumberResponse{} + + subValidator.ValidateNumber(ctx, req, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) + } +} diff --git a/numbervalidator/all_example_test.go b/numbervalidator/all_example_test.go new file mode 100644 index 0000000..69f77f9 --- /dev/null +++ b/numbervalidator/all_example_test.go @@ -0,0 +1,32 @@ +package numbervalidator_test + +import ( + "math/big" + + "github.com/hashicorp/terraform-plugin-framework-validators/numbervalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleAll() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.NumberAttribute{ + Required: true, + Validators: []validator.Number{ + // Validate this Number value must either be: + // - 1.0 + // - 2.0, but not 3.0 + numbervalidator.Any( + numbervalidator.OneOf(big.NewFloat(1.0)), + numbervalidator.All( + numbervalidator.OneOf(big.NewFloat(2.0)), + numbervalidator.NoneOf(big.NewFloat(3.0)), + ), + ), + }, + }, + }, + } +} diff --git a/numbervalidator/all_test.go b/numbervalidator/all_test.go new file mode 100644 index 0000000..0782ecf --- /dev/null +++ b/numbervalidator/all_test.go @@ -0,0 +1,71 @@ +package numbervalidator_test + +import ( + "context" + "math/big" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/numbervalidator" +) + +func TestAllValidatorValidateNumber(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.Number + validators []validator.Number + expected diag.Diagnostics + } + tests := map[string]testCase{ + "invalid": { + val: types.NumberValue(big.NewFloat(1.2)), + validators: []validator.Number{ + numbervalidator.OneOf(big.NewFloat(3)), + numbervalidator.OneOf(big.NewFloat(5)), + }, + expected: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid Attribute Value Match", + "Attribute test value must be one of: [\"3\"], got: 1.2", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid Attribute Value Match", + "Attribute test value must be one of: [\"5\"], got: 1.2", + ), + }, + }, + "valid": { + val: types.NumberValue(big.NewFloat(1.2)), + validators: []validator.Number{ + numbervalidator.OneOf(big.NewFloat(1.2)), + numbervalidator.NoneOf(big.NewFloat(2.4)), + }, + expected: nil, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + request := validator.NumberRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + } + response := validator.NumberResponse{} + numbervalidator.All(test.validators...).ValidateNumber(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, test.expected); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/numbervalidator/also_requires.go b/numbervalidator/also_requires.go new file mode 100644 index 0000000..b6e39aa --- /dev/null +++ b/numbervalidator/also_requires.go @@ -0,0 +1,23 @@ +package numbervalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// AlsoRequires checks that a set of path.Expression has a non-null value, +// if the current attribute or block also has a non-null value. +// +// This implements the validation logic declaratively within the schema. +// Refer to [datasourcevalidator.RequiredTogether], +// [providervalidator.RequiredTogether], or [resourcevalidator.RequiredTogether] +// for declaring this type of validation outside the schema definition. +// +// Relative path.Expression will be resolved using the attribute or block +// being validated. +func AlsoRequires(expressions ...path.Expression) validator.Number { + return schemavalidator.AlsoRequiresValidator{ + PathExpressions: expressions, + } +} diff --git a/numbervalidator/also_requires_example_test.go b/numbervalidator/also_requires_example_test.go new file mode 100644 index 0000000..810c745 --- /dev/null +++ b/numbervalidator/also_requires_example_test.go @@ -0,0 +1,28 @@ +package numbervalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/numbervalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleAlsoRequires() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.NumberAttribute{ + Optional: true, + Validators: []validator.Number{ + // Validate this attribute must be configured with other_attr. + numbervalidator.AlsoRequires(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.StringAttribute{ + Optional: true, + }, + }, + } +} diff --git a/numbervalidator/any.go b/numbervalidator/any.go new file mode 100644 index 0000000..baaa530 --- /dev/null +++ b/numbervalidator/any.go @@ -0,0 +1,62 @@ +package numbervalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// 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 ...validator.Number) validator.Number { + return anyValidator{ + validators: validators, + } +} + +var _ validator.Number = anyValidator{} + +// anyValidator implements the validator. +type anyValidator struct { + validators []validator.Number +} + +// 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) +} + +// ValidateNumber performs the validation. +func (v anyValidator) ValidateNumber(ctx context.Context, req validator.NumberRequest, resp *validator.NumberResponse) { + for _, subValidator := range v.validators { + validateResp := &validator.NumberResponse{} + + subValidator.ValidateNumber(ctx, req, validateResp) + + if !validateResp.Diagnostics.HasError() { + resp.Diagnostics = validateResp.Diagnostics + + return + } + + resp.Diagnostics.Append(validateResp.Diagnostics...) + } +} diff --git a/numbervalidator/any_example_test.go b/numbervalidator/any_example_test.go new file mode 100644 index 0000000..f214fd2 --- /dev/null +++ b/numbervalidator/any_example_test.go @@ -0,0 +1,29 @@ +package numbervalidator_test + +import ( + "math/big" + + "github.com/hashicorp/terraform-plugin-framework-validators/numbervalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleAny() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.NumberAttribute{ + Required: true, + Validators: []validator.Number{ + // Validate this Number value must either be: + // - 1.0 + // - Not 2.0 + numbervalidator.Any( + numbervalidator.OneOf(big.NewFloat(1.0)), + numbervalidator.NoneOf(big.NewFloat(2.0)), + ), + }, + }, + }, + } +} diff --git a/numbervalidator/any_test.go b/numbervalidator/any_test.go new file mode 100644 index 0000000..d2fff07 --- /dev/null +++ b/numbervalidator/any_test.go @@ -0,0 +1,82 @@ +package numbervalidator_test + +import ( + "context" + "math/big" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/internal/testvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/numbervalidator" +) + +func TestAnyValidatorValidateNumber(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.Number + validators []validator.Number + expected diag.Diagnostics + } + tests := map[string]testCase{ + "invalid": { + val: types.NumberValue(big.NewFloat(1.2)), + validators: []validator.Number{ + numbervalidator.OneOf(big.NewFloat(3)), + numbervalidator.OneOf(big.NewFloat(5)), + }, + expected: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid Attribute Value Match", + "Attribute test value must be one of: [\"3\"], got: 1.2", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid Attribute Value Match", + "Attribute test value must be one of: [\"5\"], got: 1.2", + ), + }, + }, + "valid": { + val: types.NumberValue(big.NewFloat(4)), + validators: []validator.Number{ + numbervalidator.OneOf(big.NewFloat(4)), + numbervalidator.OneOf(big.NewFloat(5)), + }, + expected: diag.Diagnostics{}, + }, + "valid with warning": { + val: types.NumberValue(big.NewFloat(4)), + validators: []validator.Number{ + numbervalidator.All(numbervalidator.OneOf(big.NewFloat(5)), testvalidator.WarningNumber("failing warning summary", "failing warning details")), + numbervalidator.All(numbervalidator.OneOf(big.NewFloat(4)), testvalidator.WarningNumber("passing warning summary", "passing warning details")), + }, + expected: diag.Diagnostics{ + diag.NewWarningDiagnostic("passing warning summary", "passing warning details"), + }, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + request := validator.NumberRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + } + response := validator.NumberResponse{} + numbervalidator.Any(test.validators...).ValidateNumber(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, test.expected); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/numbervalidator/any_with_all_warnings.go b/numbervalidator/any_with_all_warnings.go new file mode 100644 index 0000000..341f2cb --- /dev/null +++ b/numbervalidator/any_with_all_warnings.go @@ -0,0 +1,64 @@ +package numbervalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// 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 ...validator.Number) validator.Number { + return anyWithAllWarningsValidator{ + validators: validators, + } +} + +var _ validator.Number = anyWithAllWarningsValidator{} + +// anyWithAllWarningsValidator implements the validator. +type anyWithAllWarningsValidator struct { + validators []validator.Number +} + +// 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) +} + +// ValidateNumber performs the validation. +func (v anyWithAllWarningsValidator) ValidateNumber(ctx context.Context, req validator.NumberRequest, resp *validator.NumberResponse) { + anyValid := false + + for _, subValidator := range v.validators { + validateResp := &validator.NumberResponse{} + + subValidator.ValidateNumber(ctx, req, validateResp) + + if !validateResp.Diagnostics.HasError() { + anyValid = true + } + + resp.Diagnostics.Append(validateResp.Diagnostics...) + } + + if anyValid { + resp.Diagnostics = resp.Diagnostics.Warnings() + } +} diff --git a/numbervalidator/any_with_all_warnings_example_test.go b/numbervalidator/any_with_all_warnings_example_test.go new file mode 100644 index 0000000..88af3ca --- /dev/null +++ b/numbervalidator/any_with_all_warnings_example_test.go @@ -0,0 +1,29 @@ +package numbervalidator_test + +import ( + "math/big" + + "github.com/hashicorp/terraform-plugin-framework-validators/numbervalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleAnyWithAllWarnings() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.NumberAttribute{ + Required: true, + Validators: []validator.Number{ + // Validate this Number value must either be: + // - 1.0 + // - Not 2.0 + numbervalidator.AnyWithAllWarnings( + numbervalidator.OneOf(big.NewFloat(1.0)), + numbervalidator.NoneOf(big.NewFloat(2.0)), + ), + }, + }, + }, + } +} diff --git a/numbervalidator/any_with_all_warnings_test.go b/numbervalidator/any_with_all_warnings_test.go new file mode 100644 index 0000000..5751c65 --- /dev/null +++ b/numbervalidator/any_with_all_warnings_test.go @@ -0,0 +1,83 @@ +package numbervalidator_test + +import ( + "context" + "math/big" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/internal/testvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/numbervalidator" +) + +func TestAnyWithAllWarningsValidatorValidateNumber(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.Number + validators []validator.Number + expected diag.Diagnostics + } + tests := map[string]testCase{ + "invalid": { + val: types.NumberValue(big.NewFloat(1.2)), + validators: []validator.Number{ + numbervalidator.OneOf(big.NewFloat(3)), + numbervalidator.OneOf(big.NewFloat(5)), + }, + expected: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid Attribute Value Match", + "Attribute test value must be one of: [\"3\"], got: 1.2", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid Attribute Value Match", + "Attribute test value must be one of: [\"5\"], got: 1.2", + ), + }, + }, + "valid": { + val: types.NumberValue(big.NewFloat(4)), + validators: []validator.Number{ + numbervalidator.OneOf(big.NewFloat(4)), + numbervalidator.OneOf(big.NewFloat(5)), + }, + expected: diag.Diagnostics{}, + }, + "valid with warning": { + val: types.NumberValue(big.NewFloat(4)), + validators: []validator.Number{ + numbervalidator.All(numbervalidator.OneOf(big.NewFloat(5)), testvalidator.WarningNumber("failing warning summary", "failing warning details")), + numbervalidator.All(numbervalidator.OneOf(big.NewFloat(4)), testvalidator.WarningNumber("passing warning summary", "passing warning details")), + }, + expected: diag.Diagnostics{ + diag.NewWarningDiagnostic("failing warning summary", "failing warning details"), + diag.NewWarningDiagnostic("passing warning summary", "passing warning details"), + }, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + request := validator.NumberRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + } + response := validator.NumberResponse{} + numbervalidator.AnyWithAllWarnings(test.validators...).ValidateNumber(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, test.expected); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/numbervalidator/at_least_one_of.go b/numbervalidator/at_least_one_of.go new file mode 100644 index 0000000..8909e87 --- /dev/null +++ b/numbervalidator/at_least_one_of.go @@ -0,0 +1,24 @@ +package numbervalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// AtLeastOneOf checks that of a set of path.Expression, +// including the attribute this validator is applied to, +// at least one has a non-null value. +// +// This implements the validation logic declaratively within the tfsdk.Schema. +// Refer to [datasourcevalidator.AtLeastOneOf], +// [providervalidator.AtLeastOneOf], or [resourcevalidator.AtLeastOneOf] +// for declaring this type of validation outside the schema definition. +// +// Any relative path.Expression will be resolved using the attribute being +// validated. +func AtLeastOneOf(expressions ...path.Expression) validator.Number { + return schemavalidator.AtLeastOneOfValidator{ + PathExpressions: expressions, + } +} diff --git a/numbervalidator/at_least_one_of_example_test.go b/numbervalidator/at_least_one_of_example_test.go new file mode 100644 index 0000000..5a41542 --- /dev/null +++ b/numbervalidator/at_least_one_of_example_test.go @@ -0,0 +1,28 @@ +package numbervalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/numbervalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleAtLeastOneOf() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.NumberAttribute{ + Optional: true, + Validators: []validator.Number{ + // Validate at least this attribute or other_attr should be configured. + numbervalidator.AtLeastOneOf(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.StringAttribute{ + Optional: true, + }, + }, + } +} diff --git a/numbervalidator/conflicts_with.go b/numbervalidator/conflicts_with.go new file mode 100644 index 0000000..96225df --- /dev/null +++ b/numbervalidator/conflicts_with.go @@ -0,0 +1,24 @@ +package numbervalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// ConflictsWith checks that a set of path.Expression, +// including the attribute the validator is applied to, +// do not have a value simultaneously. +// +// This implements the validation logic declaratively within the schema. +// Refer to [datasourcevalidator.Conflicting], +// [providervalidator.Conflicting], or [resourcevalidator.Conflicting] +// for declaring this type of validation outside the schema definition. +// +// Relative path.Expression will be resolved using the attribute being +// validated. +func ConflictsWith(expressions ...path.Expression) validator.Number { + return schemavalidator.ConflictsWithValidator{ + PathExpressions: expressions, + } +} diff --git a/numbervalidator/conflicts_with_example_test.go b/numbervalidator/conflicts_with_example_test.go new file mode 100644 index 0000000..ef1c4df --- /dev/null +++ b/numbervalidator/conflicts_with_example_test.go @@ -0,0 +1,28 @@ +package numbervalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/numbervalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleConflictsWith() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.NumberAttribute{ + Optional: true, + Validators: []validator.Number{ + // Validate this attribute must not be configured with other_attr. + numbervalidator.ConflictsWith(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.StringAttribute{ + Optional: true, + }, + }, + } +} diff --git a/numbervalidator/exactly_one_of.go b/numbervalidator/exactly_one_of.go new file mode 100644 index 0000000..667cd06 --- /dev/null +++ b/numbervalidator/exactly_one_of.go @@ -0,0 +1,25 @@ +package numbervalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// ExactlyOneOf checks that of a set of path.Expression, +// including the attribute the validator is applied to, +// one and only one attribute has a value. +// It will also cause a validation error if none are specified. +// +// This implements the validation logic declaratively within the schema. +// Refer to [datasourcevalidator.ExactlyOneOf], +// [providervalidator.ExactlyOneOf], or [resourcevalidator.ExactlyOneOf] +// for declaring this type of validation outside the schema definition. +// +// Relative path.Expression will be resolved using the attribute being +// validated. +func ExactlyOneOf(expressions ...path.Expression) validator.Number { + return schemavalidator.ExactlyOneOfValidator{ + PathExpressions: expressions, + } +} diff --git a/numbervalidator/exactly_one_of_example_test.go b/numbervalidator/exactly_one_of_example_test.go new file mode 100644 index 0000000..150a172 --- /dev/null +++ b/numbervalidator/exactly_one_of_example_test.go @@ -0,0 +1,28 @@ +package numbervalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/numbervalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleExactlyOneOf() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.NumberAttribute{ + Optional: true, + Validators: []validator.Number{ + // Validate only this attribute or other_attr is configured. + numbervalidator.ExactlyOneOf(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.StringAttribute{ + Optional: true, + }, + }, + } +} diff --git a/numbervalidator/none_of.go b/numbervalidator/none_of.go index 367d48b..4a12a2f 100644 --- a/numbervalidator/none_of.go +++ b/numbervalidator/none_of.go @@ -1,22 +1,63 @@ package numbervalidator import ( + "context" + "fmt" "math/big" - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" - "github.com/hashicorp/terraform-plugin-framework-validators/internal/primitivevalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" ) -// NoneOf checks that the *big.Float held in the attribute -// is none of the given `unacceptableFloats`. -func NoneOf(unacceptableFloats ...*big.Float) tfsdk.AttributeValidator { - unacceptableFloatValues := make([]attr.Value, 0, len(unacceptableFloats)) - for _, f := range unacceptableFloats { - unacceptableFloatValues = append(unacceptableFloatValues, types.NumberValue(f)) +var _ validator.Number = noneOfValidator{} + +// noneOfValidator validates that the value does not match one of the values. +type noneOfValidator struct { + values []types.Number +} + +func (v noneOfValidator) Description(ctx context.Context) string { + return v.MarkdownDescription(ctx) +} + +func (v noneOfValidator) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("value must be none of: %q", v.values) +} + +func (v noneOfValidator) ValidateNumber(ctx context.Context, request validator.NumberRequest, response *validator.NumberResponse) { + if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() { + return } - return primitivevalidator.NoneOf(unacceptableFloatValues...) + value := request.ConfigValue + + for _, otherValue := range v.values { + if !value.Equal(otherValue) { + continue + } + + response.Diagnostics.Append(validatordiag.InvalidAttributeValueMatchDiagnostic( + request.Path, + v.Description(ctx), + value.String(), + )) + + break + } +} + +// NoneOf checks that the Number held in the attribute +// is none of the given `values`. +func NoneOf(values ...*big.Float) validator.Number { + frameworkValues := make([]types.Number, 0, len(values)) + + for _, value := range values { + frameworkValues = append(frameworkValues, types.NumberValue(value)) + } + + return noneOfValidator{ + values: frameworkValues, + } } diff --git a/numbervalidator/none_of_example_test.go b/numbervalidator/none_of_example_test.go index dff8e3f..4eeaab8 100644 --- a/numbervalidator/none_of_example_test.go +++ b/numbervalidator/none_of_example_test.go @@ -4,18 +4,17 @@ import ( "math/big" "github.com/hashicorp/terraform-plugin-framework-validators/numbervalidator" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" ) func ExampleNoneOf() { - // Used within a GetSchema method of a DataSource, Provider, or Resource - _ = tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "example_attr": { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.NumberAttribute{ Required: true, - Type: types.NumberType, - Validators: []tfsdk.AttributeValidator{ + Validators: []validator.Number{ // Validate number value must not be 1.2, 2.4, or 4.8 numbervalidator.NoneOf( []*big.Float{ diff --git a/numbervalidator/none_of_test.go b/numbervalidator/none_of_test.go index 95a819a..548a990 100644 --- a/numbervalidator/none_of_test.go +++ b/numbervalidator/none_of_test.go @@ -5,8 +5,7 @@ import ( "math/big" "testing" - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework-validators/numbervalidator" @@ -16,8 +15,8 @@ func TestNoneOfValidator(t *testing.T) { t.Parallel() type testCase struct { - in attr.Value - validator tfsdk.AttributeValidator + in types.Number + validator validator.Number expErrors int } @@ -64,11 +63,11 @@ func TestNoneOfValidator(t *testing.T) { for name, test := range testCases { name, test := name, test t.Run(name, func(t *testing.T) { - req := tfsdk.ValidateAttributeRequest{ - AttributeConfig: test.in, + req := validator.NumberRequest{ + ConfigValue: test.in, } - res := tfsdk.ValidateAttributeResponse{} - test.validator.Validate(context.TODO(), req, &res) + res := validator.NumberResponse{} + test.validator.ValidateNumber(context.TODO(), req, &res) if test.expErrors > 0 && !res.Diagnostics.HasError() { t.Fatalf("expected %d error(s), got none", test.expErrors) diff --git a/numbervalidator/one_of.go b/numbervalidator/one_of.go index 24f4c71..32e252f 100644 --- a/numbervalidator/one_of.go +++ b/numbervalidator/one_of.go @@ -1,22 +1,61 @@ package numbervalidator import ( + "context" + "fmt" "math/big" - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" - "github.com/hashicorp/terraform-plugin-framework-validators/internal/primitivevalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" ) -// OneOf checks that the *big.Float held in the attribute -// is one of the given `acceptableFloats`. -func OneOf(acceptableFloats ...*big.Float) tfsdk.AttributeValidator { - acceptableFloatValues := make([]attr.Value, 0, len(acceptableFloats)) - for _, f := range acceptableFloats { - acceptableFloatValues = append(acceptableFloatValues, types.NumberValue(f)) +var _ validator.Number = oneOfValidator{} + +// oneOfValidator validates that the value matches one of expected values. +type oneOfValidator struct { + values []types.Number +} + +func (v oneOfValidator) Description(ctx context.Context) string { + return v.MarkdownDescription(ctx) +} + +func (v oneOfValidator) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("value must be one of: %q", v.values) +} + +func (v oneOfValidator) ValidateNumber(ctx context.Context, request validator.NumberRequest, response *validator.NumberResponse) { + if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() { + return + } + + value := request.ConfigValue + + for _, otherValue := range v.values { + if value.Equal(otherValue) { + return + } + } + + response.Diagnostics.Append(validatordiag.InvalidAttributeValueMatchDiagnostic( + request.Path, + v.Description(ctx), + value.String(), + )) +} + +// OneOf checks that the Number held in the attribute +// is none of the given `values`. +func OneOf(values ...*big.Float) validator.Number { + frameworkValues := make([]types.Number, 0, len(values)) + + for _, value := range values { + frameworkValues = append(frameworkValues, types.NumberValue(value)) } - return primitivevalidator.OneOf(acceptableFloatValues...) + return oneOfValidator{ + values: frameworkValues, + } } diff --git a/numbervalidator/one_of_example_test.go b/numbervalidator/one_of_example_test.go index b0eb38f..bb7f14d 100644 --- a/numbervalidator/one_of_example_test.go +++ b/numbervalidator/one_of_example_test.go @@ -4,18 +4,17 @@ import ( "math/big" "github.com/hashicorp/terraform-plugin-framework-validators/numbervalidator" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" ) func ExampleOneOf() { - // Used within a GetSchema method of a DataSource, Provider, or Resource - _ = tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "example_attr": { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.NumberAttribute{ Required: true, - Type: types.NumberType, - Validators: []tfsdk.AttributeValidator{ + Validators: []validator.Number{ // Validate number value must be 1.2, 2.4, or 4.8 numbervalidator.OneOf( []*big.Float{ diff --git a/numbervalidator/one_of_test.go b/numbervalidator/one_of_test.go index ea78875..0e3451c 100644 --- a/numbervalidator/one_of_test.go +++ b/numbervalidator/one_of_test.go @@ -5,8 +5,7 @@ import ( "math/big" "testing" - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework-validators/numbervalidator" @@ -16,8 +15,8 @@ func TestOneOfValidator(t *testing.T) { t.Parallel() type testCase struct { - in attr.Value - validator tfsdk.AttributeValidator + in types.Number + validator validator.Number expErrors int } @@ -64,11 +63,11 @@ func TestOneOfValidator(t *testing.T) { for name, test := range testCases { name, test := name, test t.Run(name, func(t *testing.T) { - req := tfsdk.ValidateAttributeRequest{ - AttributeConfig: test.in, + req := validator.NumberRequest{ + ConfigValue: test.in, } - res := tfsdk.ValidateAttributeResponse{} - test.validator.Validate(context.TODO(), req, &res) + res := validator.NumberResponse{} + test.validator.ValidateNumber(context.TODO(), req, &res) if test.expErrors > 0 && !res.Diagnostics.HasError() { t.Fatalf("expected %d error(s), got none", test.expErrors) diff --git a/objectvalidator/all.go b/objectvalidator/all.go new file mode 100644 index 0000000..a63a73a --- /dev/null +++ b/objectvalidator/all.go @@ -0,0 +1,54 @@ +package objectvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// All returns a validator which ensures that any configured attribute value +// 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 ...validator.Object) validator.Object { + return allValidator{ + validators: validators, + } +} + +var _ validator.Object = allValidator{} + +// allValidator implements the validator. +type allValidator struct { + validators []validator.Object +} + +// 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) +} + +// ValidateObject performs the validation. +func (v allValidator) ValidateObject(ctx context.Context, req validator.ObjectRequest, resp *validator.ObjectResponse) { + for _, subValidator := range v.validators { + validateResp := &validator.ObjectResponse{} + + subValidator.ValidateObject(ctx, req, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) + } +} diff --git a/objectvalidator/all_example_test.go b/objectvalidator/all_example_test.go new file mode 100644 index 0000000..4041e0f --- /dev/null +++ b/objectvalidator/all_example_test.go @@ -0,0 +1,25 @@ +package objectvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/objectvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleAll() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.ObjectAttribute{ + Required: true, + Validators: []validator.Object{ + // This Object must satify either All validator. + objectvalidator.Any( + objectvalidator.All( /* ... */ ), + objectvalidator.All( /* ... */ ), + ), + }, + }, + }, + } +} diff --git a/objectvalidator/all_test.go b/objectvalidator/all_test.go new file mode 100644 index 0000000..7703040 --- /dev/null +++ b/objectvalidator/all_test.go @@ -0,0 +1,97 @@ +package objectvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/internal/testvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/objectvalidator" +) + +func TestAllValidatorValidateObject(t *testing.T) { + t.Parallel() + + testValue := types.ObjectValueMust( + map[string]attr.Type{ + "testattr": types.StringType, + }, + map[string]attr.Value{ + "testattr": types.StringValue("test"), + }, + ) + + type testCase struct { + val types.Object + validators []validator.Object + expected diag.Diagnostics + } + tests := map[string]testCase{ + "invalid": { + val: testValue, + validators: []validator.Object{ + testvalidator.ObjectValidator{ + Diagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Error Summary 1", + "Error Detail 1", + ), + }, + }, + testvalidator.ObjectValidator{ + Diagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Error Summary 2", + "Error Detail 2", + ), + }, + }, + }, + expected: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Error Summary 1", + "Error Detail 1", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Error Summary 2", + "Error Detail 2", + ), + }, + }, + "valid": { + val: testValue, + validators: []validator.Object{ + testvalidator.ObjectValidator{}, + testvalidator.ObjectValidator{}, + }, + expected: nil, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + request := validator.ObjectRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + } + response := validator.ObjectResponse{} + objectvalidator.All(test.validators...).ValidateObject(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, test.expected); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/objectvalidator/also_requires.go b/objectvalidator/also_requires.go new file mode 100644 index 0000000..876076c --- /dev/null +++ b/objectvalidator/also_requires.go @@ -0,0 +1,23 @@ +package objectvalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// AlsoRequires checks that a set of path.Expression has a non-null value, +// if the current attribute or block also has a non-null value. +// +// This implements the validation logic declaratively within the schema. +// Refer to [datasourcevalidator.RequiredTogether], +// [providervalidator.RequiredTogether], or [resourcevalidator.RequiredTogether] +// for declaring this type of validation outside the schema definition. +// +// Relative path.Expression will be resolved using the attribute or block +// being validated. +func AlsoRequires(expressions ...path.Expression) validator.Object { + return schemavalidator.AlsoRequiresValidator{ + PathExpressions: expressions, + } +} diff --git a/objectvalidator/also_requires_example_test.go b/objectvalidator/also_requires_example_test.go new file mode 100644 index 0000000..69b7cbe --- /dev/null +++ b/objectvalidator/also_requires_example_test.go @@ -0,0 +1,28 @@ +package objectvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/objectvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleAlsoRequires() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.ObjectAttribute{ + Optional: true, + Validators: []validator.Object{ + // Validate this attribute must be configured with other_attr. + objectvalidator.AlsoRequires(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.StringAttribute{ + Optional: true, + }, + }, + } +} diff --git a/objectvalidator/any.go b/objectvalidator/any.go new file mode 100644 index 0000000..9619677 --- /dev/null +++ b/objectvalidator/any.go @@ -0,0 +1,62 @@ +package objectvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// 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 ...validator.Object) validator.Object { + return anyValidator{ + validators: validators, + } +} + +var _ validator.Object = anyValidator{} + +// anyValidator implements the validator. +type anyValidator struct { + validators []validator.Object +} + +// 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) +} + +// ValidateObject performs the validation. +func (v anyValidator) ValidateObject(ctx context.Context, req validator.ObjectRequest, resp *validator.ObjectResponse) { + for _, subValidator := range v.validators { + validateResp := &validator.ObjectResponse{} + + subValidator.ValidateObject(ctx, req, validateResp) + + if !validateResp.Diagnostics.HasError() { + resp.Diagnostics = validateResp.Diagnostics + + return + } + + resp.Diagnostics.Append(validateResp.Diagnostics...) + } +} diff --git a/objectvalidator/any_example_test.go b/objectvalidator/any_example_test.go new file mode 100644 index 0000000..857aba4 --- /dev/null +++ b/objectvalidator/any_example_test.go @@ -0,0 +1,21 @@ +package objectvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/objectvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleAny() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.ObjectAttribute{ + Required: true, + Validators: []validator.Object{ + objectvalidator.Any( /* ... */ ), + }, + }, + }, + } +} diff --git a/objectvalidator/any_test.go b/objectvalidator/any_test.go new file mode 100644 index 0000000..5e455a1 --- /dev/null +++ b/objectvalidator/any_test.go @@ -0,0 +1,140 @@ +package objectvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/internal/testvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/objectvalidator" +) + +func TestAnyValidatorValidateObject(t *testing.T) { + t.Parallel() + + testValue := types.ObjectValueMust( + map[string]attr.Type{ + "testattr": types.StringType, + }, + map[string]attr.Value{ + "testattr": types.StringValue("test"), + }, + ) + + type testCase struct { + val types.Object + validators []validator.Object + expected diag.Diagnostics + } + tests := map[string]testCase{ + "invalid": { + val: testValue, + validators: []validator.Object{ + testvalidator.ObjectValidator{ + Diagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Error Summary 1", + "Error Detail 1", + ), + }, + }, + testvalidator.ObjectValidator{ + Diagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Error Summary 2", + "Error Detail 2", + ), + }, + }, + }, + expected: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Error Summary 1", + "Error Detail 1", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Error Summary 2", + "Error Detail 2", + ), + }, + }, + "valid": { + val: testValue, + validators: []validator.Object{ + testvalidator.ObjectValidator{ + Diagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Error Summary", + "Error Detail", + ), + }, + }, + testvalidator.ObjectValidator{}, + }, + expected: diag.Diagnostics{}, + }, + "valid with warning": { + val: testValue, + validators: []validator.Object{ + testvalidator.ObjectValidator{ + Diagnostics: diag.Diagnostics{ + diag.NewAttributeWarningDiagnostic( + path.Root("test"), + "Failing Warning Summary", + "Failing Warning Detail", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Failing Error Summary", + "Failing Error Detail", + ), + }, + }, + testvalidator.ObjectValidator{ + Diagnostics: diag.Diagnostics{ + diag.NewAttributeWarningDiagnostic( + path.Root("test"), + "Passing Warning Summary", + "Passing Warning Detail", + ), + }, + }, + }, + expected: diag.Diagnostics{ + diag.NewAttributeWarningDiagnostic( + path.Root("test"), + "Passing Warning Summary", + "Passing Warning Detail", + ), + }, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + request := validator.ObjectRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + } + response := validator.ObjectResponse{} + objectvalidator.Any(test.validators...).ValidateObject(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, test.expected); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/objectvalidator/any_with_all_warnings.go b/objectvalidator/any_with_all_warnings.go new file mode 100644 index 0000000..ae7ba0a --- /dev/null +++ b/objectvalidator/any_with_all_warnings.go @@ -0,0 +1,64 @@ +package objectvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// 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 ...validator.Object) validator.Object { + return anyWithAllWarningsValidator{ + validators: validators, + } +} + +var _ validator.Object = anyWithAllWarningsValidator{} + +// anyWithAllWarningsValidator implements the validator. +type anyWithAllWarningsValidator struct { + validators []validator.Object +} + +// 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) +} + +// ValidateObject performs the validation. +func (v anyWithAllWarningsValidator) ValidateObject(ctx context.Context, req validator.ObjectRequest, resp *validator.ObjectResponse) { + anyValid := false + + for _, subValidator := range v.validators { + validateResp := &validator.ObjectResponse{} + + subValidator.ValidateObject(ctx, req, validateResp) + + if !validateResp.Diagnostics.HasError() { + anyValid = true + } + + resp.Diagnostics.Append(validateResp.Diagnostics...) + } + + if anyValid { + resp.Diagnostics = resp.Diagnostics.Warnings() + } +} diff --git a/objectvalidator/any_with_all_warnings_example_test.go b/objectvalidator/any_with_all_warnings_example_test.go new file mode 100644 index 0000000..3a3279b --- /dev/null +++ b/objectvalidator/any_with_all_warnings_example_test.go @@ -0,0 +1,21 @@ +package objectvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/objectvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleAnyWithAllWarnings() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.ObjectAttribute{ + Required: true, + Validators: []validator.Object{ + objectvalidator.AnyWithAllWarnings( /* ... */ ), + }, + }, + }, + } +} diff --git a/objectvalidator/any_with_all_warnings_test.go b/objectvalidator/any_with_all_warnings_test.go new file mode 100644 index 0000000..b24fa7b --- /dev/null +++ b/objectvalidator/any_with_all_warnings_test.go @@ -0,0 +1,145 @@ +package objectvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/internal/testvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/objectvalidator" +) + +func TestAnyWithAllWarningsValidatorValidateObject(t *testing.T) { + t.Parallel() + + testValue := types.ObjectValueMust( + map[string]attr.Type{ + "testattr": types.StringType, + }, + map[string]attr.Value{ + "testattr": types.StringValue("test"), + }, + ) + + type testCase struct { + val types.Object + validators []validator.Object + expected diag.Diagnostics + } + tests := map[string]testCase{ + "invalid": { + val: testValue, + validators: []validator.Object{ + testvalidator.ObjectValidator{ + Diagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Error Summary 1", + "Error Detail 1", + ), + }, + }, + testvalidator.ObjectValidator{ + Diagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Error Summary 2", + "Error Detail 2", + ), + }, + }, + }, + expected: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Error Summary 1", + "Error Detail 1", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Error Summary 2", + "Error Detail 2", + ), + }, + }, + "valid": { + val: testValue, + validators: []validator.Object{ + testvalidator.ObjectValidator{ + Diagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Error Summary", + "Error Detail", + ), + }, + }, + testvalidator.ObjectValidator{}, + }, + expected: diag.Diagnostics{}, + }, + "valid with warning": { + val: testValue, + validators: []validator.Object{ + testvalidator.ObjectValidator{ + Diagnostics: diag.Diagnostics{ + diag.NewAttributeWarningDiagnostic( + path.Root("test"), + "Failing Warning Summary", + "Failing Warning Detail", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Failing Error Summary", + "Failing Error Detail", + ), + }, + }, + testvalidator.ObjectValidator{ + Diagnostics: diag.Diagnostics{ + diag.NewAttributeWarningDiagnostic( + path.Root("test"), + "Passing Warning Summary", + "Passing Warning Detail", + ), + }, + }, + }, + expected: diag.Diagnostics{ + diag.NewAttributeWarningDiagnostic( + path.Root("test"), + "Failing Warning Summary", + "Failing Warning Detail", + ), + diag.NewAttributeWarningDiagnostic( + path.Root("test"), + "Passing Warning Summary", + "Passing Warning Detail", + ), + }, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + request := validator.ObjectRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + } + response := validator.ObjectResponse{} + objectvalidator.AnyWithAllWarnings(test.validators...).ValidateObject(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, test.expected); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/objectvalidator/at_least_one_of.go b/objectvalidator/at_least_one_of.go new file mode 100644 index 0000000..d9a4132 --- /dev/null +++ b/objectvalidator/at_least_one_of.go @@ -0,0 +1,24 @@ +package objectvalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// AtLeastOneOf checks that of a set of path.Expression, +// including the attribute this validator is applied to, +// at least one has a non-null value. +// +// This implements the validation logic declaratively within the tfsdk.Schema. +// Refer to [datasourcevalidator.AtLeastOneOf], +// [providervalidator.AtLeastOneOf], or [resourcevalidator.AtLeastOneOf] +// for declaring this type of validation outside the schema definition. +// +// Any relative path.Expression will be resolved using the attribute being +// validated. +func AtLeastOneOf(expressions ...path.Expression) validator.Object { + return schemavalidator.AtLeastOneOfValidator{ + PathExpressions: expressions, + } +} diff --git a/objectvalidator/at_least_one_of_example_test.go b/objectvalidator/at_least_one_of_example_test.go new file mode 100644 index 0000000..c1ace9f --- /dev/null +++ b/objectvalidator/at_least_one_of_example_test.go @@ -0,0 +1,28 @@ +package objectvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/objectvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleAtLeastOneOf() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.ObjectAttribute{ + Optional: true, + Validators: []validator.Object{ + // Validate at least this attribute or other_attr should be configured. + objectvalidator.AtLeastOneOf(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.StringAttribute{ + Optional: true, + }, + }, + } +} diff --git a/objectvalidator/conflicts_with.go b/objectvalidator/conflicts_with.go new file mode 100644 index 0000000..90da81d --- /dev/null +++ b/objectvalidator/conflicts_with.go @@ -0,0 +1,24 @@ +package objectvalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// ConflictsWith checks that a set of path.Expression, +// including the attribute the validator is applied to, +// do not have a value simultaneously. +// +// This implements the validation logic declaratively within the schema. +// Refer to [datasourcevalidator.Conflicting], +// [providervalidator.Conflicting], or [resourcevalidator.Conflicting] +// for declaring this type of validation outside the schema definition. +// +// Relative path.Expression will be resolved using the attribute being +// validated. +func ConflictsWith(expressions ...path.Expression) validator.Object { + return schemavalidator.ConflictsWithValidator{ + PathExpressions: expressions, + } +} diff --git a/objectvalidator/conflicts_with_example_test.go b/objectvalidator/conflicts_with_example_test.go new file mode 100644 index 0000000..560a734 --- /dev/null +++ b/objectvalidator/conflicts_with_example_test.go @@ -0,0 +1,28 @@ +package objectvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/objectvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleConflictsWith() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.ObjectAttribute{ + Optional: true, + Validators: []validator.Object{ + // Validate this attribute must not be configured with other_attr. + objectvalidator.ConflictsWith(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.StringAttribute{ + Optional: true, + }, + }, + } +} diff --git a/objectvalidator/doc.go b/objectvalidator/doc.go new file mode 100644 index 0000000..27184a1 --- /dev/null +++ b/objectvalidator/doc.go @@ -0,0 +1,2 @@ +// Package objectvalidator provides validators for types.Object attributes. +package objectvalidator diff --git a/objectvalidator/exactly_one_of.go b/objectvalidator/exactly_one_of.go new file mode 100644 index 0000000..a0d2a4d --- /dev/null +++ b/objectvalidator/exactly_one_of.go @@ -0,0 +1,25 @@ +package objectvalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// ExactlyOneOf checks that of a set of path.Expression, +// including the attribute the validator is applied to, +// one and only one attribute has a value. +// It will also cause a validation error if none are specified. +// +// This implements the validation logic declaratively within the schema. +// Refer to [datasourcevalidator.ExactlyOneOf], +// [providervalidator.ExactlyOneOf], or [resourcevalidator.ExactlyOneOf] +// for declaring this type of validation outside the schema definition. +// +// Relative path.Expression will be resolved using the attribute being +// validated. +func ExactlyOneOf(expressions ...path.Expression) validator.Object { + return schemavalidator.ExactlyOneOfValidator{ + PathExpressions: expressions, + } +} diff --git a/objectvalidator/exactly_one_of_example_test.go b/objectvalidator/exactly_one_of_example_test.go new file mode 100644 index 0000000..6b19ff0 --- /dev/null +++ b/objectvalidator/exactly_one_of_example_test.go @@ -0,0 +1,28 @@ +package objectvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/objectvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleExactlyOneOf() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.ObjectAttribute{ + Optional: true, + Validators: []validator.Object{ + // Validate only this attribute or other_attr is configured. + objectvalidator.ExactlyOneOf(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.StringAttribute{ + Optional: true, + }, + }, + } +} diff --git a/providervalidator/at_least_one_of_test.go b/providervalidator/at_least_one_of_test.go index 18914ec..fcb9bd2 100644 --- a/providervalidator/at_least_one_of_test.go +++ b/providervalidator/at_least_one_of_test.go @@ -9,8 +9,8 @@ import ( "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/provider/schema" "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" ) @@ -28,15 +28,13 @@ func TestAtLeastOneOf(t *testing.T) { }, req: provider.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -63,19 +61,16 @@ func TestAtLeastOneOf(t *testing.T) { }, req: provider.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, diff --git a/providervalidator/conflicting_test.go b/providervalidator/conflicting_test.go index 9a30098..a18e34a 100644 --- a/providervalidator/conflicting_test.go +++ b/providervalidator/conflicting_test.go @@ -9,8 +9,8 @@ import ( "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/provider/schema" "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" ) @@ -28,15 +28,13 @@ func TestConflicting(t *testing.T) { }, req: provider.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -63,19 +61,16 @@ func TestConflicting(t *testing.T) { }, req: provider.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, diff --git a/providervalidator/exactly_one_of_test.go b/providervalidator/exactly_one_of_test.go index e52eae8..9058258 100644 --- a/providervalidator/exactly_one_of_test.go +++ b/providervalidator/exactly_one_of_test.go @@ -9,8 +9,8 @@ import ( "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/provider/schema" "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" ) @@ -28,15 +28,13 @@ func TestExactlyOneOf(t *testing.T) { }, req: provider.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -63,19 +61,16 @@ func TestExactlyOneOf(t *testing.T) { }, req: provider.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, diff --git a/providervalidator/required_together_test.go b/providervalidator/required_together_test.go index 1bd5774..305125c 100644 --- a/providervalidator/required_together_test.go +++ b/providervalidator/required_together_test.go @@ -9,8 +9,8 @@ import ( "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/provider/schema" "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" ) @@ -28,15 +28,13 @@ func TestRequiredTogether(t *testing.T) { }, req: provider.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -63,19 +61,16 @@ func TestRequiredTogether(t *testing.T) { }, req: provider.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, diff --git a/resourcevalidator/at_least_one_of_test.go b/resourcevalidator/at_least_one_of_test.go index 2d0762a..4155f38 100644 --- a/resourcevalidator/at_least_one_of_test.go +++ b/resourcevalidator/at_least_one_of_test.go @@ -9,8 +9,8 @@ import ( "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" ) @@ -28,15 +28,13 @@ func TestAtLeastOneOf(t *testing.T) { }, req: resource.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -63,19 +61,16 @@ func TestAtLeastOneOf(t *testing.T) { }, req: resource.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, diff --git a/resourcevalidator/conflicting_test.go b/resourcevalidator/conflicting_test.go index ca4fa5c..079b6ca 100644 --- a/resourcevalidator/conflicting_test.go +++ b/resourcevalidator/conflicting_test.go @@ -9,8 +9,8 @@ import ( "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" ) @@ -28,15 +28,13 @@ func TestConflicting(t *testing.T) { }, req: resource.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -63,19 +61,16 @@ func TestConflicting(t *testing.T) { }, req: resource.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, diff --git a/resourcevalidator/exactly_one_of_test.go b/resourcevalidator/exactly_one_of_test.go index f39eb45..7bd9771 100644 --- a/resourcevalidator/exactly_one_of_test.go +++ b/resourcevalidator/exactly_one_of_test.go @@ -9,8 +9,8 @@ import ( "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" ) @@ -28,15 +28,13 @@ func TestExactlyOneOf(t *testing.T) { }, req: resource.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -63,19 +61,16 @@ func TestExactlyOneOf(t *testing.T) { }, req: resource.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, diff --git a/resourcevalidator/required_together_test.go b/resourcevalidator/required_together_test.go index bbfbffb..94f12e5 100644 --- a/resourcevalidator/required_together_test.go +++ b/resourcevalidator/required_together_test.go @@ -9,8 +9,8 @@ import ( "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" ) @@ -28,15 +28,13 @@ func TestRequiredTogether(t *testing.T) { }, req: resource.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, @@ -63,19 +61,16 @@ func TestRequiredTogether(t *testing.T) { }, req: resource.ValidateConfigRequest{ Config: tfsdk.Config{ - Schema: tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "test1": { + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "test2": { + "test2": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, - "other": { + "other": schema.StringAttribute{ Optional: true, - Type: types.StringType, }, }, }, diff --git a/schemavalidator/also_requires.go b/schemavalidator/also_requires.go deleted file mode 100644 index deff2f0..0000000 --- a/schemavalidator/also_requires.go +++ /dev/null @@ -1,88 +0,0 @@ -package schemavalidator - -import ( - "context" - "fmt" - - "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" -) - -// alsoRequiresAttributeValidator is the underlying struct implementing AlsoRequires. -type alsoRequiresAttributeValidator struct { - pathExpressions path.Expressions -} - -// AlsoRequires checks that a set of path.Expression has a non-null value, -// if the current attribute also has a non-null value. -// -// This implements the validation logic declaratively within the tfsdk.Schema. -// Refer to [datasourcevalidator.RequiredTogether], -// [providervalidator.RequiredTogether], or [resourcevalidator.RequiredTogether] -// for declaring this type of validation outside the schema definition. -// -// Relative path.Expression will be resolved against the validated attribute. -func AlsoRequires(attributePaths ...path.Expression) tfsdk.AttributeValidator { - return &alsoRequiresAttributeValidator{attributePaths} -} - -var _ tfsdk.AttributeValidator = (*alsoRequiresAttributeValidator)(nil) - -func (av alsoRequiresAttributeValidator) Description(ctx context.Context) string { - return av.MarkdownDescription(ctx) -} - -func (av alsoRequiresAttributeValidator) MarkdownDescription(_ context.Context) string { - return fmt.Sprintf("Ensure that if an attribute is set, also these are set: %q", av.pathExpressions) -} - -func (av alsoRequiresAttributeValidator) Validate(ctx context.Context, req tfsdk.ValidateAttributeRequest, res *tfsdk.ValidateAttributeResponse) { - // If attribute configuration is null, there is nothing else to validate - if req.AttributeConfig.IsNull() { - return - } - - expressions := req.AttributePathExpression.MergeExpressions(av.pathExpressions...) - - for _, expression := range expressions { - matchedPaths, diags := req.Config.PathMatches(ctx, expression) - - res.Diagnostics.Append(diags...) - - // Collect all errors - if diags.HasError() { - continue - } - - for _, mp := range matchedPaths { - // If the user specifies the same attribute this validator is applied to, - // also as part of the input, skip it - if mp.Equal(req.AttributePath) { - continue - } - - var mpVal attr.Value - diags := req.Config.GetAttribute(ctx, mp, &mpVal) - res.Diagnostics.Append(diags...) - - // Collect all errors - if diags.HasError() { - continue - } - - // Delay validation until all involved attribute have a known value - if mpVal.IsUnknown() { - return - } - - if mpVal.IsNull() { - res.Diagnostics.Append(validatordiag.InvalidAttributeCombinationDiagnostic( - req.AttributePath, - fmt.Sprintf("Attribute %q must be specified when %q is specified", mp, req.AttributePath), - )) - } - } - } -} diff --git a/schemavalidator/also_requires_example_test.go b/schemavalidator/also_requires_example_test.go deleted file mode 100644 index ce49b00..0000000 --- a/schemavalidator/also_requires_example_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package schemavalidator_test - -import ( - "github.com/hashicorp/terraform-plugin-framework-validators/schemavalidator" - "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" -) - -func ExampleAlsoRequires() { - // Used within a GetSchema method of a DataSource, Provider, or Resource - _ = tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "example_attr": { - Required: true, - Type: types.StringType, - Validators: []tfsdk.AttributeValidator{ - // Validate this attribute must be configured with other_attr. - schemavalidator.AlsoRequires(path.Expressions{ - path.MatchRoot("other_attr"), - }...), - }, - }, - "other_attr": { - Required: true, - Type: types.StringType, - }, - }, - } -} diff --git a/schemavalidator/at_least_one_of.go b/schemavalidator/at_least_one_of.go deleted file mode 100644 index 54539ad..0000000 --- a/schemavalidator/at_least_one_of.go +++ /dev/null @@ -1,85 +0,0 @@ -package schemavalidator - -import ( - "context" - "fmt" - - "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" -) - -// atLeastOneOfAttributeValidator is the underlying struct implementing AtLeastOneOf. -type atLeastOneOfAttributeValidator struct { - pathExpressions path.Expressions -} - -// AtLeastOneOf checks that of a set of path.Expression, -// including the attribute it's applied to, -// at least one attribute out of all specified is has a non-null value. -// -// This implements the validation logic declaratively within the tfsdk.Schema. -// Refer to [datasourcevalidator.AtLeastOneOf], -// [providervalidator.AtLeastOneOf], or [resourcevalidator.AtLeastOneOf] -// for declaring this type of validation outside the schema definition. -// -// Any relative path.Expression will be resolved against the attribute with this validator. -func AtLeastOneOf(attributePaths ...path.Expression) tfsdk.AttributeValidator { - return &atLeastOneOfAttributeValidator{attributePaths} -} - -var _ tfsdk.AttributeValidator = (*atLeastOneOfAttributeValidator)(nil) - -func (av atLeastOneOfAttributeValidator) Description(ctx context.Context) string { - return av.MarkdownDescription(ctx) -} - -func (av atLeastOneOfAttributeValidator) MarkdownDescription(_ context.Context) string { - return fmt.Sprintf("Ensure that at least one attribute from this collection is set: %s", av.pathExpressions) -} - -func (av atLeastOneOfAttributeValidator) Validate(ctx context.Context, req tfsdk.ValidateAttributeRequest, res *tfsdk.ValidateAttributeResponse) { - // If attribute configuration is not null, validator already succeeded. - if !req.AttributeConfig.IsNull() { - return - } - - expressions := req.AttributePathExpression.MergeExpressions(av.pathExpressions...) - - for _, expression := range expressions { - matchedPaths, diags := req.Config.PathMatches(ctx, expression) - - res.Diagnostics.Append(diags...) - - // Collect all errors - if diags.HasError() { - continue - } - - for _, mp := range matchedPaths { - var mpVal attr.Value - diags := req.Config.GetAttribute(ctx, mp, &mpVal) - res.Diagnostics.Append(diags...) - - // Collect all errors - if diags.HasError() { - continue - } - - // Delay validation until all involved attribute have a known value - if mpVal.IsUnknown() { - return - } - - if !mpVal.IsNull() { - return - } - } - } - - res.Diagnostics.Append(validatordiag.InvalidAttributeCombinationDiagnostic( - req.AttributePath, - fmt.Sprintf("At least one attribute out of %s must be specified", expressions), - )) -} diff --git a/schemavalidator/at_least_one_of_example_test.go b/schemavalidator/at_least_one_of_example_test.go deleted file mode 100644 index a95de22..0000000 --- a/schemavalidator/at_least_one_of_example_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package schemavalidator_test - -import ( - "github.com/hashicorp/terraform-plugin-framework-validators/schemavalidator" - "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" -) - -func ExampleAtLeastOneOf() { - // Used within a GetSchema method of a DataSource, Provider, or Resource - _ = tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "example_attr": { - Required: true, - Type: types.StringType, - Validators: []tfsdk.AttributeValidator{ - // Validate at least this attribute or other_attr should be configured. - schemavalidator.AtLeastOneOf(path.Expressions{ - path.MatchRoot("other_attr"), - }...), - }, - }, - "other_attr": { - Required: true, - Type: types.StringType, - }, - }, - } -} diff --git a/schemavalidator/conflicts_with.go b/schemavalidator/conflicts_with.go deleted file mode 100644 index 157b6e6..0000000 --- a/schemavalidator/conflicts_with.go +++ /dev/null @@ -1,88 +0,0 @@ -package schemavalidator - -import ( - "context" - "fmt" - - "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" -) - -// conflictsWithAttributeValidator is the underlying struct implementing ConflictsWith. -type conflictsWithAttributeValidator struct { - pathExpressions path.Expressions -} - -// ConflictsWith checks that a set of path.Expression, -// including the attribute it's applied to, do not have a value simultaneously. -// -// This implements the validation logic declaratively within the tfsdk.Schema. -// Refer to [datasourcevalidator.Conflicting], -// [providervalidator.Conflicting], or [resourcevalidator.Conflicting] -// for declaring this type of validation outside the schema definition. -// -// Relative path.Expression will be resolved against the validated attribute. -func ConflictsWith(attributePaths ...path.Expression) tfsdk.AttributeValidator { - return &conflictsWithAttributeValidator{attributePaths} -} - -var _ tfsdk.AttributeValidator = (*conflictsWithAttributeValidator)(nil) - -func (av conflictsWithAttributeValidator) Description(ctx context.Context) string { - return av.MarkdownDescription(ctx) -} - -func (av conflictsWithAttributeValidator) MarkdownDescription(_ context.Context) string { - return fmt.Sprintf("Ensure that if an attribute is set, these are not set: %q", av.pathExpressions) -} - -func (av conflictsWithAttributeValidator) Validate(ctx context.Context, req tfsdk.ValidateAttributeRequest, res *tfsdk.ValidateAttributeResponse) { - // If attribute configuration is null, it cannot conflict with others - if req.AttributeConfig.IsNull() { - return - } - - expressions := req.AttributePathExpression.MergeExpressions(av.pathExpressions...) - - for _, expression := range expressions { - matchedPaths, diags := req.Config.PathMatches(ctx, expression) - - res.Diagnostics.Append(diags...) - - // Collect all errors - if diags.HasError() { - continue - } - - for _, mp := range matchedPaths { - // If the user specifies the same attribute this validator is applied to, - // also as part of the input, skip it - if mp.Equal(req.AttributePath) { - continue - } - - var mpVal attr.Value - diags := req.Config.GetAttribute(ctx, mp, &mpVal) - res.Diagnostics.Append(diags...) - - // Collect all errors - if diags.HasError() { - continue - } - - // Delay validation until all involved attribute have a known value - if mpVal.IsUnknown() { - return - } - - if !mpVal.IsNull() { - res.Diagnostics.Append(validatordiag.InvalidAttributeCombinationDiagnostic( - req.AttributePath, - fmt.Sprintf("Attribute %q cannot be specified when %q is specified", mp, req.AttributePath), - )) - } - } - } -} diff --git a/schemavalidator/conflicts_with_example_test.go b/schemavalidator/conflicts_with_example_test.go deleted file mode 100644 index 54128f8..0000000 --- a/schemavalidator/conflicts_with_example_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package schemavalidator_test - -import ( - "github.com/hashicorp/terraform-plugin-framework-validators/schemavalidator" - "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" -) - -func ExampleConflictsWith() { - // Used within a GetSchema method of a DataSource, Provider, or Resource - _ = tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "example_attr": { - Required: true, - Type: types.StringType, - Validators: []tfsdk.AttributeValidator{ - // Validate this attribute must not be configured with other_attr. - schemavalidator.ConflictsWith(path.Expressions{ - path.MatchRoot("other_attr"), - }...), - }, - }, - "other_attr": { - Required: true, - Type: types.StringType, - }, - }, - } -} diff --git a/schemavalidator/exactly_one_of.go b/schemavalidator/exactly_one_of.go deleted file mode 100644 index 9d3a7c5..0000000 --- a/schemavalidator/exactly_one_of.go +++ /dev/null @@ -1,110 +0,0 @@ -package schemavalidator - -import ( - "context" - "fmt" - - "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" -) - -// exactlyOneOfAttributeValidator is the underlying struct implementing ExactlyOneOf. -type exactlyOneOfAttributeValidator struct { - pathExpressions path.Expressions -} - -// ExactlyOneOf checks that of a set of path.Expression, -// including the attribute it's applied to, -// one and only one attribute out of all specified has a value. -// It will also cause a validation error if none are specified. -// -// This implements the validation logic declaratively within the tfsdk.Schema. -// Refer to [datasourcevalidator.ExactlyOneOf], -// [providervalidator.ExactlyOneOf], or [resourcevalidator.ExactlyOneOf] -// for declaring this type of validation outside the schema definition. -// -// Relative path.Expression will be resolved against the validated attribute. -func ExactlyOneOf(attributePaths ...path.Expression) tfsdk.AttributeValidator { - return &exactlyOneOfAttributeValidator{attributePaths} -} - -var _ tfsdk.AttributeValidator = (*exactlyOneOfAttributeValidator)(nil) - -func (av exactlyOneOfAttributeValidator) Description(ctx context.Context) string { - return av.MarkdownDescription(ctx) -} - -func (av exactlyOneOfAttributeValidator) MarkdownDescription(_ context.Context) string { - return fmt.Sprintf("Ensure that one and only one attribute from this collection is set: %q", av.pathExpressions) -} - -func (av exactlyOneOfAttributeValidator) Validate(ctx context.Context, req tfsdk.ValidateAttributeRequest, res *tfsdk.ValidateAttributeResponse) { - count := 0 - expressions := req.AttributePathExpression.MergeExpressions(av.pathExpressions...) - - // If current attribute is unknown, delay validation - if req.AttributeConfig.IsUnknown() { - return - } - - // Now that we know the current attribute is known, check whether it is - // null to determine if it should contribute to the count. Later logic - // will remove a duplicate matching path, should it be included in the - // given expressions. - if !req.AttributeConfig.IsNull() { - count++ - } - - for _, expression := range expressions { - matchedPaths, diags := req.Config.PathMatches(ctx, expression) - - res.Diagnostics.Append(diags...) - - // Collect all errors - if diags.HasError() { - continue - } - - for _, mp := range matchedPaths { - // If the user specifies the same attribute this validator is applied to, - // also as part of the input, skip it - if mp.Equal(req.AttributePath) { - continue - } - - var mpVal attr.Value - diags := req.Config.GetAttribute(ctx, mp, &mpVal) - res.Diagnostics.Append(diags...) - - // Collect all errors - if diags.HasError() { - continue - } - - // Delay validation until all involved attribute have a known value - if mpVal.IsUnknown() { - return - } - - if !mpVal.IsNull() { - count++ - } - } - } - - if count == 0 { - res.Diagnostics.Append(validatordiag.InvalidAttributeCombinationDiagnostic( - req.AttributePath, - fmt.Sprintf("No attribute specified when one (and only one) of %s is required", expressions), - )) - } - - if count > 1 { - res.Diagnostics.Append(validatordiag.InvalidAttributeCombinationDiagnostic( - req.AttributePath, - fmt.Sprintf("%d attributes specified when one (and only one) of %s is required", count, expressions), - )) - } -} diff --git a/schemavalidator/exactly_one_of_example_test.go b/schemavalidator/exactly_one_of_example_test.go deleted file mode 100644 index e3245f5..0000000 --- a/schemavalidator/exactly_one_of_example_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package schemavalidator_test - -import ( - "github.com/hashicorp/terraform-plugin-framework-validators/schemavalidator" - "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" -) - -func ExampleExactlyOneOf() { - // Used within a GetSchema method of a DataSource, Provider, or Resource - _ = tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "example_attr": { - Required: true, - Type: types.StringType, - Validators: []tfsdk.AttributeValidator{ - // Validate only this attribute or other_attr is configured. - schemavalidator.ExactlyOneOf(path.Expressions{ - path.MatchRoot("other_attr"), - }...), - }, - }, - "other_attr": { - Required: true, - Type: types.StringType, - }, - }, - } -} diff --git a/setvalidator/all.go b/setvalidator/all.go new file mode 100644 index 0000000..bfe54fc --- /dev/null +++ b/setvalidator/all.go @@ -0,0 +1,54 @@ +package setvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// All returns a validator which ensures that any configured attribute value +// 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 ...validator.Set) validator.Set { + return allValidator{ + validators: validators, + } +} + +var _ validator.Set = allValidator{} + +// allValidator implements the validator. +type allValidator struct { + validators []validator.Set +} + +// 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) +} + +// ValidateSet performs the validation. +func (v allValidator) ValidateSet(ctx context.Context, req validator.SetRequest, resp *validator.SetResponse) { + for _, subValidator := range v.validators { + validateResp := &validator.SetResponse{} + + subValidator.ValidateSet(ctx, req, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) + } +} diff --git a/setvalidator/all_example_test.go b/setvalidator/all_example_test.go new file mode 100644 index 0000000..62e1bf7 --- /dev/null +++ b/setvalidator/all_example_test.go @@ -0,0 +1,32 @@ +package setvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ExampleAll() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.SetAttribute{ + ElementType: types.StringType, + Required: true, + Validators: []validator.Set{ + // Validate this Set value must either be: + // - More than 5 elements + // - At least 2 elements, but not more than 3 elements + setvalidator.Any( + setvalidator.SizeAtLeast(5), + setvalidator.All( + setvalidator.SizeAtLeast(2), + setvalidator.SizeAtMost(3), + ), + ), + }, + }, + }, + } +} diff --git a/setvalidator/all_test.go b/setvalidator/all_test.go new file mode 100644 index 0000000..286fbac --- /dev/null +++ b/setvalidator/all_test.go @@ -0,0 +1,83 @@ +package setvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" +) + +func TestAllValidatorValidateSet(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.Set + validators []validator.Set + expected diag.Diagnostics + } + tests := map[string]testCase{ + "invalid": { + val: types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + validators: []validator.Set{ + setvalidator.SizeAtLeast(3), + setvalidator.SizeAtLeast(5), + }, + expected: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid Attribute Value", + "Attribute test set must contain at least 3 elements, got: 2", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid Attribute Value", + "Attribute test set must contain at least 5 elements, got: 2", + ), + }, + }, + "valid": { + val: types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + validators: []validator.Set{ + setvalidator.SizeAtLeast(0), + setvalidator.SizeAtLeast(1), + }, + expected: nil, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + request := validator.SetRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + } + response := validator.SetResponse{} + setvalidator.All(test.validators...).ValidateSet(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, test.expected); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/setvalidator/also_requires.go b/setvalidator/also_requires.go new file mode 100644 index 0000000..1064f3f --- /dev/null +++ b/setvalidator/also_requires.go @@ -0,0 +1,23 @@ +package setvalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// AlsoRequires checks that a set of path.Expression has a non-null value, +// if the current attribute or block also has a non-null value. +// +// This implements the validation logic declaratively within the schema. +// Refer to [datasourcevalidator.RequiredTogether], +// [providervalidator.RequiredTogether], or [resourcevalidator.RequiredTogether] +// for declaring this type of validation outside the schema definition. +// +// Relative path.Expression will be resolved using the attribute or block +// being validated. +func AlsoRequires(expressions ...path.Expression) validator.Set { + return schemavalidator.AlsoRequiresValidator{ + PathExpressions: expressions, + } +} diff --git a/setvalidator/also_requires_example_test.go b/setvalidator/also_requires_example_test.go new file mode 100644 index 0000000..0edab62 --- /dev/null +++ b/setvalidator/also_requires_example_test.go @@ -0,0 +1,30 @@ +package setvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ExampleAlsoRequires() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.SetAttribute{ + ElementType: types.StringType, + Optional: true, + Validators: []validator.Set{ + // Validate this attribute must be configured with other_attr. + setvalidator.AlsoRequires(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.StringAttribute{ + Optional: true, + }, + }, + } +} diff --git a/setvalidator/any.go b/setvalidator/any.go new file mode 100644 index 0000000..5deeae3 --- /dev/null +++ b/setvalidator/any.go @@ -0,0 +1,62 @@ +package setvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// 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 ...validator.Set) validator.Set { + return anyValidator{ + validators: validators, + } +} + +var _ validator.Set = anyValidator{} + +// anyValidator implements the validator. +type anyValidator struct { + validators []validator.Set +} + +// 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) +} + +// ValidateSet performs the validation. +func (v anyValidator) ValidateSet(ctx context.Context, req validator.SetRequest, resp *validator.SetResponse) { + for _, subValidator := range v.validators { + validateResp := &validator.SetResponse{} + + subValidator.ValidateSet(ctx, req, validateResp) + + if !validateResp.Diagnostics.HasError() { + resp.Diagnostics = validateResp.Diagnostics + + return + } + + resp.Diagnostics.Append(validateResp.Diagnostics...) + } +} diff --git a/setvalidator/any_example_test.go b/setvalidator/any_example_test.go new file mode 100644 index 0000000..bc21865 --- /dev/null +++ b/setvalidator/any_example_test.go @@ -0,0 +1,27 @@ +package setvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleAny() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.SetAttribute{ + Required: true, + Validators: []validator.Set{ + // Validate this Set value must either be: + // - Between 1 and 2 elements + // - At least 4 elements + setvalidator.Any( + setvalidator.SizeBetween(1, 2), + setvalidator.SizeAtLeast(4), + ), + }, + }, + }, + } +} diff --git a/setvalidator/any_test.go b/setvalidator/any_test.go new file mode 100644 index 0000000..0b18ba1 --- /dev/null +++ b/setvalidator/any_test.go @@ -0,0 +1,100 @@ +package setvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/internal/testvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" +) + +func TestAnyValidatorValidateSet(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.Set + validators []validator.Set + expected diag.Diagnostics + } + tests := map[string]testCase{ + "invalid": { + val: types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + validators: []validator.Set{ + setvalidator.SizeAtLeast(3), + setvalidator.SizeAtLeast(5), + }, + expected: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid Attribute Value", + "Attribute test set must contain at least 3 elements, got: 2", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid Attribute Value", + "Attribute test set must contain at least 5 elements, got: 2", + ), + }, + }, + "valid": { + val: types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + validators: []validator.Set{ + setvalidator.SizeAtLeast(4), + setvalidator.SizeAtLeast(2), + }, + expected: diag.Diagnostics{}, + }, + "valid with warning": { + val: types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + validators: []validator.Set{ + setvalidator.All(setvalidator.SizeAtLeast(5), testvalidator.WarningSet("failing warning summary", "failing warning details")), + setvalidator.All(setvalidator.SizeAtLeast(2), testvalidator.WarningSet("passing warning summary", "passing warning details")), + }, + expected: diag.Diagnostics{ + diag.NewWarningDiagnostic("passing warning summary", "passing warning details"), + }, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + request := validator.SetRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + } + response := validator.SetResponse{} + setvalidator.Any(test.validators...).ValidateSet(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, test.expected); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/setvalidator/any_with_all_warnings.go b/setvalidator/any_with_all_warnings.go new file mode 100644 index 0000000..99f0d58 --- /dev/null +++ b/setvalidator/any_with_all_warnings.go @@ -0,0 +1,64 @@ +package setvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// 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 ...validator.Set) validator.Set { + return anyWithAllWarningsValidator{ + validators: validators, + } +} + +var _ validator.Set = anyWithAllWarningsValidator{} + +// anyWithAllWarningsValidator implements the validator. +type anyWithAllWarningsValidator struct { + validators []validator.Set +} + +// 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) +} + +// ValidateSet performs the validation. +func (v anyWithAllWarningsValidator) ValidateSet(ctx context.Context, req validator.SetRequest, resp *validator.SetResponse) { + anyValid := false + + for _, subValidator := range v.validators { + validateResp := &validator.SetResponse{} + + subValidator.ValidateSet(ctx, req, validateResp) + + if !validateResp.Diagnostics.HasError() { + anyValid = true + } + + resp.Diagnostics.Append(validateResp.Diagnostics...) + } + + if anyValid { + resp.Diagnostics = resp.Diagnostics.Warnings() + } +} diff --git a/setvalidator/any_with_all_warnings_example_test.go b/setvalidator/any_with_all_warnings_example_test.go new file mode 100644 index 0000000..9e8bf12 --- /dev/null +++ b/setvalidator/any_with_all_warnings_example_test.go @@ -0,0 +1,27 @@ +package setvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleAnyWithAllWarnings() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.SetAttribute{ + Required: true, + Validators: []validator.Set{ + // Validate this Set value must either be: + // - Between 1 and 2 elements + // - At least 4 elements + setvalidator.AnyWithAllWarnings( + setvalidator.SizeBetween(1, 2), + setvalidator.SizeAtLeast(4), + ), + }, + }, + }, + } +} diff --git a/setvalidator/any_with_all_warnings_test.go b/setvalidator/any_with_all_warnings_test.go new file mode 100644 index 0000000..cc480f3 --- /dev/null +++ b/setvalidator/any_with_all_warnings_test.go @@ -0,0 +1,101 @@ +package setvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/internal/testvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" +) + +func TestAnyWithAllWarningsValidatorValidateSet(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.Set + validators []validator.Set + expected diag.Diagnostics + } + tests := map[string]testCase{ + "invalid": { + val: types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + validators: []validator.Set{ + setvalidator.SizeAtLeast(3), + setvalidator.SizeAtLeast(5), + }, + expected: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid Attribute Value", + "Attribute test set must contain at least 3 elements, got: 2", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid Attribute Value", + "Attribute test set must contain at least 5 elements, got: 2", + ), + }, + }, + "valid": { + val: types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + validators: []validator.Set{ + setvalidator.SizeAtLeast(5), + setvalidator.SizeAtLeast(2), + }, + expected: diag.Diagnostics{}, + }, + "valid with warning": { + val: types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + validators: []validator.Set{ + setvalidator.All(setvalidator.SizeAtLeast(5), testvalidator.WarningSet("failing warning summary", "failing warning details")), + setvalidator.All(setvalidator.SizeAtLeast(2), testvalidator.WarningSet("passing warning summary", "passing warning details")), + }, + expected: diag.Diagnostics{ + diag.NewWarningDiagnostic("failing warning summary", "failing warning details"), + diag.NewWarningDiagnostic("passing warning summary", "passing warning details"), + }, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + request := validator.SetRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + } + response := validator.SetResponse{} + setvalidator.AnyWithAllWarnings(test.validators...).ValidateSet(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, test.expected); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/setvalidator/at_least_one_of.go b/setvalidator/at_least_one_of.go new file mode 100644 index 0000000..65bb3d0 --- /dev/null +++ b/setvalidator/at_least_one_of.go @@ -0,0 +1,24 @@ +package setvalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// AtLeastOneOf checks that of a set of path.Expression, +// including the attribute or block this validator is applied to, +// at least one has a non-null value. +// +// This implements the validation logic declaratively within the tfsdk.Schema. +// Refer to [datasourcevalidator.AtLeastOneOf], +// [providervalidator.AtLeastOneOf], or [resourcevalidator.AtLeastOneOf] +// for declaring this type of validation outside the schema definition. +// +// Any relative path.Expression will be resolved using the attribute or block +// being validated. +func AtLeastOneOf(expressions ...path.Expression) validator.Set { + return schemavalidator.AtLeastOneOfValidator{ + PathExpressions: expressions, + } +} diff --git a/setvalidator/at_least_one_of_example_test.go b/setvalidator/at_least_one_of_example_test.go new file mode 100644 index 0000000..c69d6db --- /dev/null +++ b/setvalidator/at_least_one_of_example_test.go @@ -0,0 +1,30 @@ +package setvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ExampleAtLeastOneOf() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.SetAttribute{ + ElementType: types.StringType, + Optional: true, + Validators: []validator.Set{ + // Validate at least this attribute or other_attr should be configured. + setvalidator.AtLeastOneOf(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.StringAttribute{ + Optional: true, + }, + }, + } +} diff --git a/setvalidator/conflicts_with.go b/setvalidator/conflicts_with.go new file mode 100644 index 0000000..478edcb --- /dev/null +++ b/setvalidator/conflicts_with.go @@ -0,0 +1,24 @@ +package setvalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// ConflictsWith checks that a set of path.Expression, +// including the attribute or block the validator is applied to, +// do not have a value simultaneously. +// +// This implements the validation logic declaratively within the schema. +// Refer to [datasourcevalidator.Conflicting], +// [providervalidator.Conflicting], or [resourcevalidator.Conflicting] +// for declaring this type of validation outside the schema definition. +// +// Relative path.Expression will be resolved using the attribute or block +// being validated. +func ConflictsWith(expressions ...path.Expression) validator.Set { + return schemavalidator.ConflictsWithValidator{ + PathExpressions: expressions, + } +} diff --git a/setvalidator/conflicts_with_example_test.go b/setvalidator/conflicts_with_example_test.go new file mode 100644 index 0000000..55638e5 --- /dev/null +++ b/setvalidator/conflicts_with_example_test.go @@ -0,0 +1,30 @@ +package setvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ExampleConflictsWith() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.SetAttribute{ + ElementType: types.StringType, + Optional: true, + Validators: []validator.Set{ + // Validate this attribute must not be configured with other_attr. + setvalidator.ConflictsWith(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.StringAttribute{ + Optional: true, + }, + }, + } +} diff --git a/setvalidator/exactly_one_of.go b/setvalidator/exactly_one_of.go new file mode 100644 index 0000000..eaf89d9 --- /dev/null +++ b/setvalidator/exactly_one_of.go @@ -0,0 +1,25 @@ +package setvalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// ExactlyOneOf checks that of a set of path.Expression, +// including the attribute or block the validator is applied to, +// one and only one attribute has a value. +// It will also cause a validation error if none are specified. +// +// This implements the validation logic declaratively within the schema. +// Refer to [datasourcevalidator.ExactlyOneOf], +// [providervalidator.ExactlyOneOf], or [resourcevalidator.ExactlyOneOf] +// for declaring this type of validation outside the schema definition. +// +// Relative path.Expression will be resolved using the attribute or block +// being validated. +func ExactlyOneOf(expressions ...path.Expression) validator.Set { + return schemavalidator.ExactlyOneOfValidator{ + PathExpressions: expressions, + } +} diff --git a/setvalidator/exactly_one_of_example_test.go b/setvalidator/exactly_one_of_example_test.go new file mode 100644 index 0000000..761bc16 --- /dev/null +++ b/setvalidator/exactly_one_of_example_test.go @@ -0,0 +1,30 @@ +package setvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ExampleExactlyOneOf() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.SetAttribute{ + ElementType: types.StringType, + Optional: true, + Validators: []validator.Set{ + // Validate only this attribute or other_attr is configured. + setvalidator.ExactlyOneOf(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.StringAttribute{ + Optional: true, + }, + }, + } +} diff --git a/setvalidator/size_at_least.go b/setvalidator/size_at_least.go index 8ed0b7c..acff6b5 100644 --- a/setvalidator/size_at_least.go +++ b/setvalidator/size_at_least.go @@ -5,10 +5,10 @@ import ( "fmt" "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" ) -var _ tfsdk.AttributeValidator = sizeAtLeastValidator{} +var _ validator.Set = sizeAtLeastValidator{} // sizeAtLeastValidator validates that set contains at least min elements. type sizeAtLeastValidator struct { @@ -26,20 +26,19 @@ func (v sizeAtLeastValidator) MarkdownDescription(ctx context.Context) string { } // Validate performs the validation. -func (v sizeAtLeastValidator) Validate(ctx context.Context, req tfsdk.ValidateAttributeRequest, resp *tfsdk.ValidateAttributeResponse) { - elems, ok := validateSet(ctx, req, resp) - if !ok { +func (v sizeAtLeastValidator) ValidateSet(ctx context.Context, req validator.SetRequest, resp *validator.SetResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { return } + elems := req.ConfigValue.Elements() + if len(elems) < v.min { resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( - req.AttributePath, + req.Path, v.Description(ctx), fmt.Sprintf("%d", len(elems)), )) - - return } } @@ -50,7 +49,7 @@ func (v sizeAtLeastValidator) Validate(ctx context.Context, req tfsdk.ValidateAt // - Contains at least min elements. // // Null (unconfigured) and unknown (known after apply) values are skipped. -func SizeAtLeast(min int) tfsdk.AttributeValidator { +func SizeAtLeast(min int) validator.Set { return sizeAtLeastValidator{ min: min, } diff --git a/setvalidator/size_at_least_example_test.go b/setvalidator/size_at_least_example_test.go index 87fcd10..f338ad6 100644 --- a/setvalidator/size_at_least_example_test.go +++ b/setvalidator/size_at_least_example_test.go @@ -2,20 +2,19 @@ package setvalidator_test import ( "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" ) func ExampleSizeAtLeast() { - // Used within a GetSchema method of a DataSource, Provider, or Resource - _ = tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "example_attr": { - Required: true, - Type: types.SetType{ - ElemType: types.StringType, - }, - Validators: []tfsdk.AttributeValidator{ + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.SetAttribute{ + ElementType: types.StringType, + Required: true, + Validators: []validator.Set{ // Validate this set must contain at least 2 elements. setvalidator.SizeAtLeast(2), }, diff --git a/setvalidator/size_at_least_test.go b/setvalidator/size_at_least_test.go index e34763e..f9e44b3 100644 --- a/setvalidator/size_at_least_test.go +++ b/setvalidator/size_at_least_test.go @@ -6,7 +6,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" ) @@ -14,15 +14,11 @@ func TestSizeAtLeastValidator(t *testing.T) { t.Parallel() type testCase struct { - val attr.Value + val types.Set min int expectError bool } tests := map[string]testCase{ - "not a Set": { - val: types.BoolValue(true), - expectError: true, - }, "Set unknown": { val: types.SetUnknown( types.StringType, @@ -69,13 +65,13 @@ func TestSizeAtLeastValidator(t *testing.T) { for name, test := range tests { name, test := name, test t.Run(name, func(t *testing.T) { - request := tfsdk.ValidateAttributeRequest{ - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - AttributeConfig: test.val, + request := validator.SetRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, } - response := tfsdk.ValidateAttributeResponse{} - SizeAtLeast(test.min).Validate(context.TODO(), request, &response) + response := validator.SetResponse{} + SizeAtLeast(test.min).ValidateSet(context.TODO(), request, &response) if !response.Diagnostics.HasError() && test.expectError { t.Fatal("expected error, got no error") diff --git a/setvalidator/size_at_most.go b/setvalidator/size_at_most.go index bc6683b..1dc460b 100644 --- a/setvalidator/size_at_most.go +++ b/setvalidator/size_at_most.go @@ -5,10 +5,10 @@ import ( "fmt" "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" ) -var _ tfsdk.AttributeValidator = sizeAtMostValidator{} +var _ validator.Set = sizeAtMostValidator{} // sizeAtMostValidator validates that set contains at most max elements. type sizeAtMostValidator struct { @@ -26,20 +26,19 @@ func (v sizeAtMostValidator) MarkdownDescription(ctx context.Context) string { } // Validate performs the validation. -func (v sizeAtMostValidator) Validate(ctx context.Context, req tfsdk.ValidateAttributeRequest, resp *tfsdk.ValidateAttributeResponse) { - elems, ok := validateSet(ctx, req, resp) - if !ok { +func (v sizeAtMostValidator) ValidateSet(ctx context.Context, req validator.SetRequest, resp *validator.SetResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { return } + elems := req.ConfigValue.Elements() + if len(elems) > v.max { resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( - req.AttributePath, + req.Path, v.Description(ctx), fmt.Sprintf("%d", len(elems)), )) - - return } } @@ -50,7 +49,7 @@ func (v sizeAtMostValidator) Validate(ctx context.Context, req tfsdk.ValidateAtt // - Contains at most max elements. // // Null (unconfigured) and unknown (known after apply) values are skipped. -func SizeAtMost(max int) tfsdk.AttributeValidator { +func SizeAtMost(max int) validator.Set { return sizeAtMostValidator{ max: max, } diff --git a/setvalidator/size_at_most_example_test.go b/setvalidator/size_at_most_example_test.go index 417247e..f551fef 100644 --- a/setvalidator/size_at_most_example_test.go +++ b/setvalidator/size_at_most_example_test.go @@ -2,20 +2,19 @@ package setvalidator_test import ( "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" ) func ExampleSizeAtMost() { - // Used within a GetSchema method of a DataSource, Provider, or Resource - _ = tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "example_attr": { - Required: true, - Type: types.SetType{ - ElemType: types.StringType, - }, - Validators: []tfsdk.AttributeValidator{ + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.SetAttribute{ + ElementType: types.StringType, + Required: true, + Validators: []validator.Set{ // Validate this set must contain at most 2 elements. setvalidator.SizeAtMost(2), }, diff --git a/setvalidator/size_at_most_test.go b/setvalidator/size_at_most_test.go index 2cc3d2a..dd776b7 100644 --- a/setvalidator/size_at_most_test.go +++ b/setvalidator/size_at_most_test.go @@ -6,7 +6,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" ) @@ -14,15 +14,11 @@ func TestSizeAtMostValidator(t *testing.T) { t.Parallel() type testCase struct { - val attr.Value + val types.Set max int expectError bool } tests := map[string]testCase{ - "not a Set": { - val: types.BoolValue(true), - expectError: true, - }, "Set unknown": { val: types.SetUnknown( types.StringType, @@ -73,13 +69,13 @@ func TestSizeAtMostValidator(t *testing.T) { for name, test := range tests { name, test := name, test t.Run(name, func(t *testing.T) { - request := tfsdk.ValidateAttributeRequest{ - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - AttributeConfig: test.val, + request := validator.SetRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, } - response := tfsdk.ValidateAttributeResponse{} - SizeAtMost(test.max).Validate(context.TODO(), request, &response) + response := validator.SetResponse{} + SizeAtMost(test.max).ValidateSet(context.TODO(), request, &response) if !response.Diagnostics.HasError() && test.expectError { t.Fatal("expected error, got no error") diff --git a/setvalidator/size_between.go b/setvalidator/size_between.go index e3172d3..9990e2a 100644 --- a/setvalidator/size_between.go +++ b/setvalidator/size_between.go @@ -5,10 +5,10 @@ import ( "fmt" "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" ) -var _ tfsdk.AttributeValidator = sizeBetweenValidator{} +var _ validator.Set = sizeBetweenValidator{} // sizeBetweenValidator validates that set contains at least min elements // and at most max elements. @@ -27,21 +27,20 @@ func (v sizeBetweenValidator) MarkdownDescription(ctx context.Context) string { return v.Description(ctx) } -// Validate performs the validation. -func (v sizeBetweenValidator) Validate(ctx context.Context, req tfsdk.ValidateAttributeRequest, resp *tfsdk.ValidateAttributeResponse) { - elems, ok := validateSet(ctx, req, resp) - if !ok { +// ValidateSet performs the validation. +func (v sizeBetweenValidator) ValidateSet(ctx context.Context, req validator.SetRequest, resp *validator.SetResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { return } + elems := req.ConfigValue.Elements() + if len(elems) < v.min || len(elems) > v.max { resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( - req.AttributePath, + req.Path, v.Description(ctx), fmt.Sprintf("%d", len(elems)), )) - - return } } @@ -52,7 +51,7 @@ func (v sizeBetweenValidator) Validate(ctx context.Context, req tfsdk.ValidateAt // - Contains at least min elements and at most max elements. // // Null (unconfigured) and unknown (known after apply) values are skipped. -func SizeBetween(min, max int) tfsdk.AttributeValidator { +func SizeBetween(min, max int) validator.Set { return sizeBetweenValidator{ min: min, max: max, diff --git a/setvalidator/size_between_example_test.go b/setvalidator/size_between_example_test.go index 991521a..3101d98 100644 --- a/setvalidator/size_between_example_test.go +++ b/setvalidator/size_between_example_test.go @@ -2,20 +2,19 @@ package setvalidator_test import ( "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" ) func ExampleSizeBetween() { - // Used within a GetSchema method of a DataSource, Provider, or Resource - _ = tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "example_attr": { - Required: true, - Type: types.SetType{ - ElemType: types.StringType, - }, - Validators: []tfsdk.AttributeValidator{ + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.SetAttribute{ + ElementType: types.StringType, + Required: true, + Validators: []validator.Set{ // Validate this set must contain at least 2 and at most 4 elements. setvalidator.SizeBetween(2, 4), }, diff --git a/setvalidator/size_between_test.go b/setvalidator/size_between_test.go index 3312f2a..c9766d6 100644 --- a/setvalidator/size_between_test.go +++ b/setvalidator/size_between_test.go @@ -6,7 +6,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" ) @@ -14,16 +14,12 @@ func TestSizeBetweenValidator(t *testing.T) { t.Parallel() type testCase struct { - val attr.Value + val types.Set min int max int expectError bool } tests := map[string]testCase{ - "not a Set": { - val: types.BoolValue(true), - expectError: true, - }, "Set unknown": { val: types.SetUnknown( types.StringType, @@ -112,13 +108,13 @@ func TestSizeBetweenValidator(t *testing.T) { for name, test := range tests { name, test := name, test t.Run(name, func(t *testing.T) { - request := tfsdk.ValidateAttributeRequest{ - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - AttributeConfig: test.val, + request := validator.SetRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, } - response := tfsdk.ValidateAttributeResponse{} - SizeBetween(test.min, test.max).Validate(context.TODO(), request, &response) + response := validator.SetResponse{} + SizeBetween(test.min, test.max).ValidateSet(context.TODO(), request, &response) if !response.Diagnostics.HasError() && test.expectError { t.Fatal("expected error, got no error") diff --git a/setvalidator/type_validation.go b/setvalidator/type_validation.go deleted file mode 100644 index 72e379a..0000000 --- a/setvalidator/type_validation.go +++ /dev/null @@ -1,28 +0,0 @@ -package setvalidator - -import ( - "context" - - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" -) - -// validateSet ensures that the request contains a Set value. -func validateSet(ctx context.Context, request tfsdk.ValidateAttributeRequest, response *tfsdk.ValidateAttributeResponse) ([]attr.Value, bool) { - var s types.Set - - diags := tfsdk.ValueAs(ctx, request.AttributeConfig, &s) - - if diags.HasError() { - response.Diagnostics.Append(diags...) - - return nil, false - } - - if s.IsUnknown() || s.IsNull() { - return nil, false - } - - return s.Elements(), true -} diff --git a/setvalidator/type_validation_test.go b/setvalidator/type_validation_test.go deleted file mode 100644 index 887309a..0000000 --- a/setvalidator/type_validation_test.go +++ /dev/null @@ -1,86 +0,0 @@ -package setvalidator - -import ( - "context" - "testing" - - "github.com/google/go-cmp/cmp" - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" -) - -func TestValidateSet(t *testing.T) { - t.Parallel() - - testCases := map[string]struct { - request tfsdk.ValidateAttributeRequest - expectedSetElems []attr.Value - expectedOk bool - }{ - "invalid-type": { - request: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.BoolValue(true), - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - }, - expectedSetElems: nil, - expectedOk: false, - }, - "set-null": { - request: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.SetNull(types.StringType), - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - }, - expectedSetElems: nil, - expectedOk: false, - }, - "set-unknown": { - request: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.SetUnknown(types.StringType), - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - }, - expectedSetElems: nil, - expectedOk: false, - }, - "set-value": { - request: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.SetValueMust( - types.StringType, - []attr.Value{ - types.StringValue("first"), - types.StringValue("second"), - }, - ), - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - }, - expectedSetElems: []attr.Value{ - types.StringValue("first"), - types.StringValue("second"), - }, - expectedOk: true, - }, - } - - for name, testCase := range testCases { - name, testCase := name, testCase - - t.Run(name, func(t *testing.T) { - t.Parallel() - - gotSetElems, gotOk := validateSet(context.Background(), testCase.request, &tfsdk.ValidateAttributeResponse{}) - - if diff := cmp.Diff(gotSetElems, testCase.expectedSetElems); diff != "" { - t.Errorf("unexpected set difference: %s", diff) - } - - if diff := cmp.Diff(gotOk, testCase.expectedOk); diff != "" { - t.Errorf("unexpected ok difference: %s", diff) - } - }) - } -} diff --git a/setvalidator/value_float64s_are.go b/setvalidator/value_float64s_are.go new file mode 100644 index 0000000..f9a9be4 --- /dev/null +++ b/setvalidator/value_float64s_are.go @@ -0,0 +1,116 @@ +package setvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// ValueFloat64sAre returns an validator which ensures that any configured +// Float64 values passes each Float64 validator. +// +// Null (unconfigured) and unknown (known after apply) values are skipped. +func ValueFloat64sAre(elementValidators ...validator.Float64) validator.Set { + return valueFloat64sAreValidator{ + elementValidators: elementValidators, + } +} + +var _ validator.Set = valueFloat64sAreValidator{} + +// valueFloat64sAreValidator validates that each Float64 member validates against each of the value validators. +type valueFloat64sAreValidator struct { + elementValidators []validator.Float64 +} + +// Description describes the validation in plain text formatting. +func (v valueFloat64sAreValidator) Description(ctx context.Context) string { + var descriptions []string + + for _, elementValidator := range v.elementValidators { + descriptions = append(descriptions, elementValidator.Description(ctx)) + } + + return fmt.Sprintf("element value must satisfy all validations: %s", strings.Join(descriptions, " + ")) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (v valueFloat64sAreValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +// ValidateFloat64 performs the validation. +func (v valueFloat64sAreValidator) ValidateSet(ctx context.Context, req validator.SetRequest, resp *validator.SetResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + _, ok := req.ConfigValue.ElementType(ctx).(types.Float64Typable) + + if !ok { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Validator for Element Type", + "While performing schema-based validation, an unexpected error occurred. "+ + "The attribute declares a Float64 values validator, however its values do not implement types.Float64Type or the types.Float64Typable interface for custom Float64 types. "+ + "Use the appropriate values validator that matches the element type. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Path: %s\n", req.Path.String())+ + fmt.Sprintf("Element Type: %T\n", req.ConfigValue.ElementType(ctx)), + ) + + return + } + + for _, element := range req.ConfigValue.Elements() { + elementPath := req.Path.AtSetValue(element) + + elementValuable, ok := element.(types.Float64Valuable) + + // The check above should have prevented this, but raise an error + // instead of a type assertion panic or skipping the element. Any issue + // here likely indicates something wrong in the framework itself. + if !ok { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Validator for Element Value", + "While performing schema-based validation, an unexpected error occurred. "+ + "The attribute declares a Float64 values validator, however its values do not implement types.Float64Type or the types.Float64Typable interface for custom Float64 types. "+ + "This is likely an issue with terraform-plugin-framework and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Path: %s\n", req.Path.String())+ + fmt.Sprintf("Element Type: %T\n", req.ConfigValue.ElementType(ctx))+ + fmt.Sprintf("Element Value Type: %T\n", element), + ) + + return + } + + elementValue, diags := elementValuable.ToFloat64Value(ctx) + + resp.Diagnostics.Append(diags...) + + // Only return early if the new diagnostics indicate an issue since + // it likely will be the same for all elements. + if diags.HasError() { + return + } + + elementReq := validator.Float64Request{ + Path: elementPath, + PathExpression: elementPath.Expression(), + ConfigValue: elementValue, + Config: req.Config, + } + + for _, elementValidator := range v.elementValidators { + elementResp := &validator.Float64Response{} + + elementValidator.ValidateFloat64(ctx, elementReq, elementResp) + + resp.Diagnostics.Append(elementResp.Diagnostics...) + } + } +} diff --git a/setvalidator/value_float64s_are_example_test.go b/setvalidator/value_float64s_are_example_test.go new file mode 100644 index 0000000..31a766d --- /dev/null +++ b/setvalidator/value_float64s_are_example_test.go @@ -0,0 +1,25 @@ +package setvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/float64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ExampleValueFloat64sAre() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.SetAttribute{ + ElementType: types.Float64Type, + Required: true, + Validators: []validator.Set{ + // Validate this Set must contain Float64 values which are at least 1.2. + setvalidator.ValueFloat64sAre(float64validator.AtLeast(1.2)), + }, + }, + }, + } +} diff --git a/setvalidator/value_float64s_are_test.go b/setvalidator/value_float64s_are_test.go new file mode 100644 index 0000000..0bde010 --- /dev/null +++ b/setvalidator/value_float64s_are_test.go @@ -0,0 +1,143 @@ +package setvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/float64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" +) + +func TestValueFloat64sAreValidatorValidateSet(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + val types.Set + elementValidators []validator.Float64 + expectedDiagnostics diag.Diagnostics + }{ + "no element validators": { + val: types.SetValueMust( + types.Float64Type, + []attr.Value{ + types.Float64Value(1), + types.Float64Value(2), + }, + ), + }, + "Set unknown": { + val: types.SetUnknown( + types.Float64Type, + ), + elementValidators: []validator.Float64{ + float64validator.AtLeast(1), + }, + }, + "Set null": { + val: types.SetNull( + types.Float64Type, + ), + elementValidators: []validator.Float64{ + float64validator.AtLeast(1), + }, + }, + "Set elements invalid": { + val: types.SetValueMust( + types.Float64Type, + []attr.Value{ + types.Float64Value(1), + types.Float64Value(2), + }, + ), + elementValidators: []validator.Float64{ + float64validator.AtLeast(3), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.Float64Value(1)), + "Invalid Attribute Value", + "Attribute test[Value(1.000000)] value must be at least 3.000000, got: 1.000000", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.Float64Value(2)), + "Invalid Attribute Value", + "Attribute test[Value(2.000000)] value must be at least 3.000000, got: 2.000000", + ), + }, + }, + "Set elements invalid for multiple validator": { + val: types.SetValueMust( + types.Float64Type, + []attr.Value{ + types.Float64Value(1), + types.Float64Value(2), + }, + ), + elementValidators: []validator.Float64{ + float64validator.AtLeast(3), + float64validator.AtLeast(4), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.Float64Value(1)), + "Invalid Attribute Value", + "Attribute test[Value(1.000000)] value must be at least 3.000000, got: 1.000000", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.Float64Value(1)), + "Invalid Attribute Value", + "Attribute test[Value(1.000000)] value must be at least 4.000000, got: 1.000000", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.Float64Value(2)), + "Invalid Attribute Value", + "Attribute test[Value(2.000000)] value must be at least 3.000000, got: 2.000000", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.Float64Value(2)), + "Invalid Attribute Value", + "Attribute test[Value(2.000000)] value must be at least 4.000000, got: 2.000000", + ), + }, + }, + "Set elements valid": { + val: types.SetValueMust( + types.Float64Type, + []attr.Value{ + types.Float64Value(1), + types.Float64Value(2), + }, + ), + elementValidators: []validator.Float64{ + float64validator.AtLeast(1), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + request := validator.SetRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: testCase.val, + } + response := validator.SetResponse{} + setvalidator.ValueFloat64sAre(testCase.elementValidators...).ValidateSet(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, testCase.expectedDiagnostics); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/setvalidator/value_int64s_are.go b/setvalidator/value_int64s_are.go new file mode 100644 index 0000000..de9fe7b --- /dev/null +++ b/setvalidator/value_int64s_are.go @@ -0,0 +1,116 @@ +package setvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// ValueInt64sAre returns an validator which ensures that any configured +// Int64 values passes each Int64 validator. +// +// Null (unconfigured) and unknown (known after apply) values are skipped. +func ValueInt64sAre(elementValidators ...validator.Int64) validator.Set { + return valueInt64sAreValidator{ + elementValidators: elementValidators, + } +} + +var _ validator.Set = valueInt64sAreValidator{} + +// valueInt64sAreValidator validates that each Int64 member validates against each of the value validators. +type valueInt64sAreValidator struct { + elementValidators []validator.Int64 +} + +// Description describes the validation in plain text formatting. +func (v valueInt64sAreValidator) Description(ctx context.Context) string { + var descriptions []string + + for _, elementValidator := range v.elementValidators { + descriptions = append(descriptions, elementValidator.Description(ctx)) + } + + return fmt.Sprintf("element value must satisfy all validations: %s", strings.Join(descriptions, " + ")) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (v valueInt64sAreValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +// ValidateInt64 performs the validation. +func (v valueInt64sAreValidator) ValidateSet(ctx context.Context, req validator.SetRequest, resp *validator.SetResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + _, ok := req.ConfigValue.ElementType(ctx).(types.Int64Typable) + + if !ok { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Validator for Element Type", + "While performing schema-based validation, an unexpected error occurred. "+ + "The attribute declares a Int64 values validator, however its values do not implement types.Int64Type or the types.Int64Typable interface for custom Int64 types. "+ + "Use the appropriate values validator that matches the element type. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Path: %s\n", req.Path.String())+ + fmt.Sprintf("Element Type: %T\n", req.ConfigValue.ElementType(ctx)), + ) + + return + } + + for _, element := range req.ConfigValue.Elements() { + elementPath := req.Path.AtSetValue(element) + + elementValuable, ok := element.(types.Int64Valuable) + + // The check above should have prevented this, but raise an error + // instead of a type assertion panic or skipping the element. Any issue + // here likely indicates something wrong in the framework itself. + if !ok { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Validator for Element Value", + "While performing schema-based validation, an unexpected error occurred. "+ + "The attribute declares a Int64 values validator, however its values do not implement types.Int64Type or the types.Int64Typable interface for custom Int64 types. "+ + "This is likely an issue with terraform-plugin-framework and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Path: %s\n", req.Path.String())+ + fmt.Sprintf("Element Type: %T\n", req.ConfigValue.ElementType(ctx))+ + fmt.Sprintf("Element Value Type: %T\n", element), + ) + + return + } + + elementValue, diags := elementValuable.ToInt64Value(ctx) + + resp.Diagnostics.Append(diags...) + + // Only return early if the new diagnostics indicate an issue since + // it likely will be the same for all elements. + if diags.HasError() { + return + } + + elementReq := validator.Int64Request{ + Path: elementPath, + PathExpression: elementPath.Expression(), + ConfigValue: elementValue, + Config: req.Config, + } + + for _, elementValidator := range v.elementValidators { + elementResp := &validator.Int64Response{} + + elementValidator.ValidateInt64(ctx, elementReq, elementResp) + + resp.Diagnostics.Append(elementResp.Diagnostics...) + } + } +} diff --git a/setvalidator/value_int64s_are_example_test.go b/setvalidator/value_int64s_are_example_test.go new file mode 100644 index 0000000..9cea4eb --- /dev/null +++ b/setvalidator/value_int64s_are_example_test.go @@ -0,0 +1,25 @@ +package setvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ExampleValueInt64sAre() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.SetAttribute{ + ElementType: types.Int64Type, + Required: true, + Validators: []validator.Set{ + // Validate this Set must contain Int64 values which are at least 1. + setvalidator.ValueInt64sAre(int64validator.AtLeast(1)), + }, + }, + }, + } +} diff --git a/setvalidator/value_int64s_are_test.go b/setvalidator/value_int64s_are_test.go new file mode 100644 index 0000000..0f7fd07 --- /dev/null +++ b/setvalidator/value_int64s_are_test.go @@ -0,0 +1,143 @@ +package setvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" +) + +func TestValueInt64sAreValidatorValidateSet(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + val types.Set + elementValidators []validator.Int64 + expectedDiagnostics diag.Diagnostics + }{ + "no element validators": { + val: types.SetValueMust( + types.Int64Type, + []attr.Value{ + types.Int64Value(1), + types.Int64Value(2), + }, + ), + }, + "Set unknown": { + val: types.SetUnknown( + types.Int64Type, + ), + elementValidators: []validator.Int64{ + int64validator.AtLeast(1), + }, + }, + "Set null": { + val: types.SetNull( + types.Int64Type, + ), + elementValidators: []validator.Int64{ + int64validator.AtLeast(1), + }, + }, + "Set elements invalid": { + val: types.SetValueMust( + types.Int64Type, + []attr.Value{ + types.Int64Value(1), + types.Int64Value(2), + }, + ), + elementValidators: []validator.Int64{ + int64validator.AtLeast(3), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.Int64Value(1)), + "Invalid Attribute Value", + "Attribute test[Value(1)] value must be at least 3, got: 1", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.Int64Value(2)), + "Invalid Attribute Value", + "Attribute test[Value(2)] value must be at least 3, got: 2", + ), + }, + }, + "Set elements invalid for multiple validator": { + val: types.SetValueMust( + types.Int64Type, + []attr.Value{ + types.Int64Value(1), + types.Int64Value(2), + }, + ), + elementValidators: []validator.Int64{ + int64validator.AtLeast(3), + int64validator.AtLeast(4), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.Int64Value(1)), + "Invalid Attribute Value", + "Attribute test[Value(1)] value must be at least 3, got: 1", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.Int64Value(1)), + "Invalid Attribute Value", + "Attribute test[Value(1)] value must be at least 4, got: 1", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.Int64Value(2)), + "Invalid Attribute Value", + "Attribute test[Value(2)] value must be at least 3, got: 2", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.Int64Value(2)), + "Invalid Attribute Value", + "Attribute test[Value(2)] value must be at least 4, got: 2", + ), + }, + }, + "Set elements valid": { + val: types.SetValueMust( + types.Int64Type, + []attr.Value{ + types.Int64Value(1), + types.Int64Value(2), + }, + ), + elementValidators: []validator.Int64{ + int64validator.AtLeast(1), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + request := validator.SetRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: testCase.val, + } + response := validator.SetResponse{} + setvalidator.ValueInt64sAre(testCase.elementValidators...).ValidateSet(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, testCase.expectedDiagnostics); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/setvalidator/value_lists_are.go b/setvalidator/value_lists_are.go new file mode 100644 index 0000000..c535267 --- /dev/null +++ b/setvalidator/value_lists_are.go @@ -0,0 +1,116 @@ +package setvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// ValueListsAre returns an validator which ensures that any configured +// List values passes each List validator. +// +// Null (unconfigured) and unknown (known after apply) values are skipped. +func ValueListsAre(elementValidators ...validator.List) validator.Set { + return valueListsAreValidator{ + elementValidators: elementValidators, + } +} + +var _ validator.Set = valueListsAreValidator{} + +// valueListsAreValidator validates that each set member validates against each of the value validators. +type valueListsAreValidator struct { + elementValidators []validator.List +} + +// Description describes the validation in plain text formatting. +func (v valueListsAreValidator) Description(ctx context.Context) string { + var descriptions []string + + for _, elementValidator := range v.elementValidators { + descriptions = append(descriptions, elementValidator.Description(ctx)) + } + + return fmt.Sprintf("element value must satisfy all validations: %s", strings.Join(descriptions, " + ")) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (v valueListsAreValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +// ValidateSet performs the validation. +func (v valueListsAreValidator) ValidateSet(ctx context.Context, req validator.SetRequest, resp *validator.SetResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + _, ok := req.ConfigValue.ElementType(ctx).(types.ListTypable) + + if !ok { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Validator for Element Type", + "While performing schema-based validation, an unexpected error occurred. "+ + "The attribute declares a List values validator, however its values do not implement types.ListType or the types.ListTypable interface for custom List types. "+ + "Use the appropriate values validator that matches the element type. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Path: %s\n", req.Path.String())+ + fmt.Sprintf("Element Type: %T\n", req.ConfigValue.ElementType(ctx)), + ) + + return + } + + for _, element := range req.ConfigValue.Elements() { + elementPath := req.Path.AtSetValue(element) + + elementValuable, ok := element.(types.ListValuable) + + // The check above should have prevented this, but raise an error + // instead of a type assertion panic or skipping the element. Any issue + // here likely indicates something wrong in the framework itself. + if !ok { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Validator for Element Value", + "While performing schema-based validation, an unexpected error occurred. "+ + "The attribute declares a List values validator, however its values do not implement types.ListType or the types.ListTypable interface for custom List types. "+ + "This is likely an issue with terraform-plugin-framework and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Path: %s\n", req.Path.String())+ + fmt.Sprintf("Element Type: %T\n", req.ConfigValue.ElementType(ctx))+ + fmt.Sprintf("Element Value Type: %T\n", element), + ) + + return + } + + elementValue, diags := elementValuable.ToListValue(ctx) + + resp.Diagnostics.Append(diags...) + + // Only return early if the new diagnostics indicate an issue since + // it likely will be the same for all elements. + if diags.HasError() { + return + } + + elementReq := validator.ListRequest{ + Path: elementPath, + PathExpression: elementPath.Expression(), + ConfigValue: elementValue, + Config: req.Config, + } + + for _, elementValidator := range v.elementValidators { + elementResp := &validator.ListResponse{} + + elementValidator.ValidateList(ctx, elementReq, elementResp) + + resp.Diagnostics.Append(elementResp.Diagnostics...) + } + } +} diff --git a/setvalidator/value_lists_are_example_test.go b/setvalidator/value_lists_are_example_test.go new file mode 100644 index 0000000..32c4bef --- /dev/null +++ b/setvalidator/value_lists_are_example_test.go @@ -0,0 +1,30 @@ +package setvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ExampleValueListsAre() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.SetAttribute{ + // This Set has values of Lists of Strings. + // Roughly equivalent to [][]string. + ElementType: types.ListType{ + ElemType: types.StringType, + }, + Required: true, + Validators: []validator.Set{ + // Validate this Set must contain List elements + // which have at least 1 String element. + setvalidator.ValueListsAre(listvalidator.SizeAtLeast(1)), + }, + }, + }, + } +} diff --git a/setvalidator/value_lists_are_test.go b/setvalidator/value_lists_are_test.go new file mode 100644 index 0000000..47b6dd6 --- /dev/null +++ b/setvalidator/value_lists_are_test.go @@ -0,0 +1,227 @@ +package setvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" +) + +func TestValueListsAreValidatorValidateSet(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + val types.Set + elementValidators []validator.List + expectedDiagnostics diag.Diagnostics + }{ + "no element validators": { + val: types.SetValueMust( + types.ListType{ElemType: types.StringType}, + []attr.Value{ + types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("third"), + types.StringValue("fourth"), + }, + ), + }, + ), + }, + "Set unknown": { + val: types.SetUnknown( + types.StringType, + ), + elementValidators: []validator.List{ + listvalidator.SizeAtLeast(1), + }, + }, + "Set null": { + val: types.SetNull( + types.StringType, + ), + elementValidators: []validator.List{ + listvalidator.SizeAtLeast(1), + }, + }, + "Set elements invalid": { + val: types.SetValueMust( + types.ListType{ElemType: types.StringType}, + []attr.Value{ + types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("third"), + types.StringValue("fourth"), + }, + ), + }, + ), + elementValidators: []validator.List{ + listvalidator.SizeAtLeast(3), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + )), + "Invalid Attribute Value", + "Attribute test[Value([\"first\",\"second\"])] list must contain at least 3 elements, got: 2", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("third"), + types.StringValue("fourth"), + }, + )), + "Invalid Attribute Value", + "Attribute test[Value([\"third\",\"fourth\"])] list must contain at least 3 elements, got: 2", + ), + }, + }, + "Set elements invalid for multiple validator": { + val: types.SetValueMust( + types.ListType{ElemType: types.StringType}, + []attr.Value{ + types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("third"), + types.StringValue("fourth"), + }, + ), + }, + ), + elementValidators: []validator.List{ + listvalidator.SizeAtLeast(3), + listvalidator.SizeAtLeast(4), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + )), + "Invalid Attribute Value", + "Attribute test[Value([\"first\",\"second\"])] list must contain at least 3 elements, got: 2", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + )), + "Invalid Attribute Value", + "Attribute test[Value([\"first\",\"second\"])] list must contain at least 4 elements, got: 2", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("third"), + types.StringValue("fourth"), + }, + )), + "Invalid Attribute Value", + "Attribute test[Value([\"third\",\"fourth\"])] list must contain at least 3 elements, got: 2", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("third"), + types.StringValue("fourth"), + }, + )), + "Invalid Attribute Value", + "Attribute test[Value([\"third\",\"fourth\"])] list must contain at least 4 elements, got: 2", + ), + }, + }, + "Set elements valid": { + val: types.SetValueMust( + types.ListType{ElemType: types.StringType}, + []attr.Value{ + types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("third"), + types.StringValue("fourth"), + }, + ), + }, + ), + elementValidators: []validator.List{ + listvalidator.SizeAtLeast(1), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + request := validator.SetRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: testCase.val, + } + response := validator.SetResponse{} + setvalidator.ValueListsAre(testCase.elementValidators...).ValidateSet(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, testCase.expectedDiagnostics); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/setvalidator/value_maps_are.go b/setvalidator/value_maps_are.go new file mode 100644 index 0000000..664fb92 --- /dev/null +++ b/setvalidator/value_maps_are.go @@ -0,0 +1,116 @@ +package setvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// ValueMapsAre returns an validator which ensures that any configured +// Map values passes each Map validator. +// +// Null (unconfigured) and unknown (known after apply) values are skipped. +func ValueMapsAre(elementValidators ...validator.Map) validator.Set { + return valueMapsAreValidator{ + elementValidators: elementValidators, + } +} + +var _ validator.Set = valueMapsAreValidator{} + +// valueMapsAreValidator validates that each set member validates against each of the value validators. +type valueMapsAreValidator struct { + elementValidators []validator.Map +} + +// Description describes the validation in plain text formatting. +func (v valueMapsAreValidator) Description(ctx context.Context) string { + var descriptions []string + + for _, elementValidator := range v.elementValidators { + descriptions = append(descriptions, elementValidator.Description(ctx)) + } + + return fmt.Sprintf("element value must satisfy all validations: %s", strings.Join(descriptions, " + ")) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (v valueMapsAreValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +// ValidateSet performs the validation. +func (v valueMapsAreValidator) ValidateSet(ctx context.Context, req validator.SetRequest, resp *validator.SetResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + _, ok := req.ConfigValue.ElementType(ctx).(types.MapTypable) + + if !ok { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Validator for Element Type", + "While performing schema-based validation, an unexpected error occurred. "+ + "The attribute declares a Map values validator, however its values do not implement types.MapType or the types.MapTypable interface for custom Map types. "+ + "Use the appropriate values validator that matches the element type. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Path: %s\n", req.Path.String())+ + fmt.Sprintf("Element Type: %T\n", req.ConfigValue.ElementType(ctx)), + ) + + return + } + + for _, element := range req.ConfigValue.Elements() { + elementPath := req.Path.AtSetValue(element) + + elementValuable, ok := element.(types.MapValuable) + + // The check above should have prevented this, but raise an error + // instead of a type assertion panic or skipping the element. Any issue + // here likely indicates something wrong in the framework itself. + if !ok { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Validator for Element Value", + "While performing schema-based validation, an unexpected error occurred. "+ + "The attribute declares a Map values validator, however its values do not implement types.MapType or the types.MapTypable interface for custom Map types. "+ + "This is likely an issue with terraform-plugin-framework and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Path: %s\n", req.Path.String())+ + fmt.Sprintf("Element Type: %T\n", req.ConfigValue.ElementType(ctx))+ + fmt.Sprintf("Element Value Type: %T\n", element), + ) + + return + } + + elementValue, diags := elementValuable.ToMapValue(ctx) + + resp.Diagnostics.Append(diags...) + + // Only return early if the new diagnostics indicate an issue since + // it likely will be the same for all elements. + if diags.HasError() { + return + } + + elementReq := validator.MapRequest{ + Path: elementPath, + PathExpression: elementPath.Expression(), + ConfigValue: elementValue, + Config: req.Config, + } + + for _, elementValidator := range v.elementValidators { + elementResp := &validator.MapResponse{} + + elementValidator.ValidateMap(ctx, elementReq, elementResp) + + resp.Diagnostics.Append(elementResp.Diagnostics...) + } + } +} diff --git a/setvalidator/value_maps_are_example_test.go b/setvalidator/value_maps_are_example_test.go new file mode 100644 index 0000000..2843c24 --- /dev/null +++ b/setvalidator/value_maps_are_example_test.go @@ -0,0 +1,30 @@ +package setvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ExampleValueMapsAre() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.SetAttribute{ + // This Set has values of Maps of Strings. + // Roughly equivalent to []map[string]string. + ElementType: types.MapType{ + ElemType: types.StringType, + }, + Required: true, + Validators: []validator.Set{ + // Validate this Set must contain Map elements + // which have at least 1 element. + setvalidator.ValueMapsAre(mapvalidator.SizeAtLeast(1)), + }, + }, + }, + } +} diff --git a/setvalidator/value_maps_are_test.go b/setvalidator/value_maps_are_test.go new file mode 100644 index 0000000..15e4d6e --- /dev/null +++ b/setvalidator/value_maps_are_test.go @@ -0,0 +1,221 @@ +package setvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" +) + +func TestValueMapsAreValidatorValidateSet(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + val types.Set + elementValidators []validator.Map + expectedDiagnostics diag.Diagnostics + }{ + "no element validators": { + val: types.SetValueMust( + types.MapType{ElemType: types.StringType}, + []attr.Value{ + types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("first"), + "Key2": types.StringValue("second"), + }, + ), + types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("third"), + "key2": types.StringValue("fourth"), + }, + ), + }, + ), + }, + "Set unknown": { + val: types.SetUnknown( + types.StringType, + ), + elementValidators: []validator.Map{ + mapvalidator.SizeAtLeast(1), + }, + }, + "Set null": { + val: types.SetNull( + types.StringType, + ), + elementValidators: []validator.Map{ + mapvalidator.SizeAtLeast(1), + }, + }, + "Set elements invalid": { + val: types.SetValueMust( + types.MapType{ElemType: types.StringType}, + []attr.Value{ + types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("first"), + // Map ordering is random in Go, avoid multiple keys + }, + ), + types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("third"), + // Map ordering is random in Go, avoid multiple keys + }, + ), + }, + ), + elementValidators: []validator.Map{ + mapvalidator.SizeAtLeast(3), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("first"), + }, + )), + "Invalid Attribute Value", + "Attribute test[Value({\"key1\":\"first\"})] map must contain at least 3 elements, got: 1", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("third"), + }, + )), + "Invalid Attribute Value", + "Attribute test[Value({\"key1\":\"third\"})] map must contain at least 3 elements, got: 1", + ), + }, + }, + "Set elements invalid for multiple validator": { + val: types.SetValueMust( + types.MapType{ElemType: types.StringType}, + []attr.Value{ + types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("first"), + // Map ordering is random in Go, avoid multiple keys + }, + ), + types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("third"), + // Map ordering is random in Go, avoid multiple keys + }, + ), + }, + ), + elementValidators: []validator.Map{ + mapvalidator.SizeAtLeast(3), + mapvalidator.SizeAtLeast(4), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("first"), + }, + )), + "Invalid Attribute Value", + "Attribute test[Value({\"key1\":\"first\"})] map must contain at least 3 elements, got: 1", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("first"), + }, + )), + "Invalid Attribute Value", + "Attribute test[Value({\"key1\":\"first\"})] map must contain at least 4 elements, got: 1", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("third"), + }, + )), + "Invalid Attribute Value", + "Attribute test[Value({\"key1\":\"third\"})] map must contain at least 3 elements, got: 1", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("third"), + }, + )), + "Invalid Attribute Value", + "Attribute test[Value({\"key1\":\"third\"})] map must contain at least 4 elements, got: 1", + ), + }, + }, + "Set elements valid": { + val: types.SetValueMust( + types.MapType{ElemType: types.StringType}, + []attr.Value{ + types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("first"), + "key2": types.StringValue("second"), + }, + ), + types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("third"), + "key2": types.StringValue("fourth"), + }, + ), + }, + ), + elementValidators: []validator.Map{ + mapvalidator.SizeAtLeast(1), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + request := validator.SetRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: testCase.val, + } + response := validator.SetResponse{} + setvalidator.ValueMapsAre(testCase.elementValidators...).ValidateSet(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, testCase.expectedDiagnostics); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/setvalidator/value_numbers_are.go b/setvalidator/value_numbers_are.go new file mode 100644 index 0000000..7efacdf --- /dev/null +++ b/setvalidator/value_numbers_are.go @@ -0,0 +1,116 @@ +package setvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// ValueNumbersAre returns an validator which ensures that any configured +// Number values passes each Number validator. +// +// Null (unconfigured) and unknown (known after apply) values are skipped. +func ValueNumbersAre(elementValidators ...validator.Number) validator.Set { + return valueNumbersAreValidator{ + elementValidators: elementValidators, + } +} + +var _ validator.Set = valueNumbersAreValidator{} + +// valueNumbersAreValidator validates that each Number member validates against each of the value validators. +type valueNumbersAreValidator struct { + elementValidators []validator.Number +} + +// Description describes the validation in plain text formatting. +func (v valueNumbersAreValidator) Description(ctx context.Context) string { + var descriptions []string + + for _, elementValidator := range v.elementValidators { + descriptions = append(descriptions, elementValidator.Description(ctx)) + } + + return fmt.Sprintf("element value must satisfy all validations: %s", strings.Join(descriptions, " + ")) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (v valueNumbersAreValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +// ValidateNumber performs the validation. +func (v valueNumbersAreValidator) ValidateSet(ctx context.Context, req validator.SetRequest, resp *validator.SetResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + _, ok := req.ConfigValue.ElementType(ctx).(types.NumberTypable) + + if !ok { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Validator for Element Type", + "While performing schema-based validation, an unexpected error occurred. "+ + "The attribute declares a Number values validator, however its values do not implement types.NumberType or the types.NumberTypable interface for custom Number types. "+ + "Use the appropriate values validator that matches the element type. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Path: %s\n", req.Path.String())+ + fmt.Sprintf("Element Type: %T\n", req.ConfigValue.ElementType(ctx)), + ) + + return + } + + for _, element := range req.ConfigValue.Elements() { + elementPath := req.Path.AtSetValue(element) + + elementValuable, ok := element.(types.NumberValuable) + + // The check above should have prevented this, but raise an error + // instead of a type assertion panic or skipping the element. Any issue + // here likely indicates something wrong in the framework itself. + if !ok { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Validator for Element Value", + "While performing schema-based validation, an unexpected error occurred. "+ + "The attribute declares a Number values validator, however its values do not implement types.NumberType or the types.NumberTypable interface for custom Number types. "+ + "This is likely an issue with terraform-plugin-framework and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Path: %s\n", req.Path.String())+ + fmt.Sprintf("Element Type: %T\n", req.ConfigValue.ElementType(ctx))+ + fmt.Sprintf("Element Value Type: %T\n", element), + ) + + return + } + + elementValue, diags := elementValuable.ToNumberValue(ctx) + + resp.Diagnostics.Append(diags...) + + // Only return early if the new diagnostics indicate an issue since + // it likely will be the same for all elements. + if diags.HasError() { + return + } + + elementReq := validator.NumberRequest{ + Path: elementPath, + PathExpression: elementPath.Expression(), + ConfigValue: elementValue, + Config: req.Config, + } + + for _, elementValidator := range v.elementValidators { + elementResp := &validator.NumberResponse{} + + elementValidator.ValidateNumber(ctx, elementReq, elementResp) + + resp.Diagnostics.Append(elementResp.Diagnostics...) + } + } +} diff --git a/setvalidator/value_numbers_are_example_test.go b/setvalidator/value_numbers_are_example_test.go new file mode 100644 index 0000000..c08638f --- /dev/null +++ b/setvalidator/value_numbers_are_example_test.go @@ -0,0 +1,32 @@ +package setvalidator_test + +import ( + "math/big" + + "github.com/hashicorp/terraform-plugin-framework-validators/numbervalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ExampleValueNumbersAre() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.SetAttribute{ + ElementType: types.NumberType, + Required: true, + Validators: []validator.Set{ + // Validate this Set must contain Number values which are 1.2 or 2.4. + setvalidator.ValueNumbersAre( + numbervalidator.OneOf( + big.NewFloat(1.2), + big.NewFloat(2.4), + ), + ), + }, + }, + }, + } +} diff --git a/setvalidator/value_numbers_are_test.go b/setvalidator/value_numbers_are_test.go new file mode 100644 index 0000000..975f86d --- /dev/null +++ b/setvalidator/value_numbers_are_test.go @@ -0,0 +1,144 @@ +package setvalidator_test + +import ( + "context" + "math/big" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/numbervalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" +) + +func TestValueNumbersAreValidatorValidateSet(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + val types.Set + elementValidators []validator.Number + expectedDiagnostics diag.Diagnostics + }{ + "no element validators": { + val: types.SetValueMust( + types.NumberType, + []attr.Value{ + types.NumberValue(big.NewFloat(1.2)), + types.NumberValue(big.NewFloat(2.4)), + }, + ), + }, + "Set unknown": { + val: types.SetUnknown( + types.NumberType, + ), + elementValidators: []validator.Number{ + numbervalidator.OneOf(big.NewFloat(1.2)), + }, + }, + "Set null": { + val: types.SetNull( + types.NumberType, + ), + elementValidators: []validator.Number{ + numbervalidator.OneOf(big.NewFloat(1.2)), + }, + }, + "Set elements invalid": { + val: types.SetValueMust( + types.NumberType, + []attr.Value{ + types.NumberValue(big.NewFloat(1.2)), + types.NumberValue(big.NewFloat(2.4)), + }, + ), + elementValidators: []validator.Number{ + numbervalidator.OneOf(big.NewFloat(3.6)), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.NumberValue(big.NewFloat(1.2))), + "Invalid Attribute Value Match", + "Attribute test[Value(1.2)] value must be one of: [\"3.6\"], got: 1.2", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.NumberValue(big.NewFloat(2.4))), + "Invalid Attribute Value Match", + "Attribute test[Value(2.4)] value must be one of: [\"3.6\"], got: 2.4", + ), + }, + }, + "Set elements invalid for multiple validator": { + val: types.SetValueMust( + types.NumberType, + []attr.Value{ + types.NumberValue(big.NewFloat(1.2)), + types.NumberValue(big.NewFloat(2.4)), + }, + ), + elementValidators: []validator.Number{ + numbervalidator.OneOf(big.NewFloat(3.6)), + numbervalidator.OneOf(big.NewFloat(4.8)), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.NumberValue(big.NewFloat(1.2))), + "Invalid Attribute Value Match", + "Attribute test[Value(1.2)] value must be one of: [\"3.6\"], got: 1.2", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.NumberValue(big.NewFloat(1.2))), + "Invalid Attribute Value Match", + "Attribute test[Value(1.2)] value must be one of: [\"4.8\"], got: 1.2", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.NumberValue(big.NewFloat(2.4))), + "Invalid Attribute Value Match", + "Attribute test[Value(2.4)] value must be one of: [\"3.6\"], got: 2.4", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.NumberValue(big.NewFloat(2.4))), + "Invalid Attribute Value Match", + "Attribute test[Value(2.4)] value must be one of: [\"4.8\"], got: 2.4", + ), + }, + }, + "Set elements valid": { + val: types.SetValueMust( + types.NumberType, + []attr.Value{ + types.NumberValue(big.NewFloat(1.2)), + types.NumberValue(big.NewFloat(2.4)), + }, + ), + elementValidators: []validator.Number{ + numbervalidator.OneOf(big.NewFloat(1.2), big.NewFloat(2.4)), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + request := validator.SetRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: testCase.val, + } + response := validator.SetResponse{} + setvalidator.ValueNumbersAre(testCase.elementValidators...).ValidateSet(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, testCase.expectedDiagnostics); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/setvalidator/value_sets_are.go b/setvalidator/value_sets_are.go new file mode 100644 index 0000000..243dd87 --- /dev/null +++ b/setvalidator/value_sets_are.go @@ -0,0 +1,116 @@ +package setvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// ValueSetsAre returns an validator which ensures that any configured +// Set values passes each Set validator. +// +// Null (unconfigured) and unknown (known after apply) values are skipped. +func ValueSetsAre(elementValidators ...validator.Set) validator.Set { + return valueSetsAreValidator{ + elementValidators: elementValidators, + } +} + +var _ validator.Set = valueSetsAreValidator{} + +// valueSetsAreValidator validates that each set member validates against each of the value validators. +type valueSetsAreValidator struct { + elementValidators []validator.Set +} + +// Description describes the validation in plain text formatting. +func (v valueSetsAreValidator) Description(ctx context.Context) string { + var descriptions []string + + for _, elementValidator := range v.elementValidators { + descriptions = append(descriptions, elementValidator.Description(ctx)) + } + + return fmt.Sprintf("element value must satisfy all validations: %s", strings.Join(descriptions, " + ")) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (v valueSetsAreValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +// ValidateSet performs the validation. +func (v valueSetsAreValidator) ValidateSet(ctx context.Context, req validator.SetRequest, resp *validator.SetResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + _, ok := req.ConfigValue.ElementType(ctx).(types.SetTypable) + + if !ok { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Validator for Element Type", + "While performing schema-based validation, an unexpected error occurred. "+ + "The attribute declares a Set values validator, however its values do not implement types.SetType or the types.SetTypable interface for custom Set types. "+ + "Use the appropriate values validator that matches the element type. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Path: %s\n", req.Path.String())+ + fmt.Sprintf("Element Type: %T\n", req.ConfigValue.ElementType(ctx)), + ) + + return + } + + for _, element := range req.ConfigValue.Elements() { + elementPath := req.Path.AtSetValue(element) + + elementValuable, ok := element.(types.SetValuable) + + // The check above should have prevented this, but raise an error + // instead of a type assertion panic or skipping the element. Any issue + // here likely indicates something wrong in the framework itself. + if !ok { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Validator for Element Value", + "While performing schema-based validation, an unexpected error occurred. "+ + "The attribute declares a Set values validator, however its values do not implement types.SetType or the types.SetTypable interface for custom Set types. "+ + "This is likely an issue with terraform-plugin-framework and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Path: %s\n", req.Path.String())+ + fmt.Sprintf("Element Type: %T\n", req.ConfigValue.ElementType(ctx))+ + fmt.Sprintf("Element Value Type: %T\n", element), + ) + + return + } + + elementValue, diags := elementValuable.ToSetValue(ctx) + + resp.Diagnostics.Append(diags...) + + // Only return early if the new diagnostics indicate an issue since + // it likely will be the same for all elements. + if diags.HasError() { + return + } + + elementReq := validator.SetRequest{ + Path: elementPath, + PathExpression: elementPath.Expression(), + ConfigValue: elementValue, + Config: req.Config, + } + + for _, elementValidator := range v.elementValidators { + elementResp := &validator.SetResponse{} + + elementValidator.ValidateSet(ctx, elementReq, elementResp) + + resp.Diagnostics.Append(elementResp.Diagnostics...) + } + } +} diff --git a/setvalidator/value_sets_are_example_test.go b/setvalidator/value_sets_are_example_test.go new file mode 100644 index 0000000..e9853f4 --- /dev/null +++ b/setvalidator/value_sets_are_example_test.go @@ -0,0 +1,29 @@ +package setvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ExampleValueSetsAre() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.SetAttribute{ + // This Set has values of Sets of Strings. + // Roughly equivalent to [][]string. + ElementType: types.SetType{ + ElemType: types.StringType, + }, + Required: true, + Validators: []validator.Set{ + // Validate this Set must contain Set elements + // which have at least 1 String element. + setvalidator.ValueSetsAre(setvalidator.SizeAtLeast(1)), + }, + }, + }, + } +} diff --git a/setvalidator/value_sets_are_test.go b/setvalidator/value_sets_are_test.go new file mode 100644 index 0000000..0b0b4ea --- /dev/null +++ b/setvalidator/value_sets_are_test.go @@ -0,0 +1,226 @@ +package setvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" +) + +func TestValueSetsAreValidatorValidateSet(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + val types.Set + elementValidators []validator.Set + expectedDiagnostics diag.Diagnostics + }{ + "no element validators": { + val: types.SetValueMust( + types.SetType{ElemType: types.StringType}, + []attr.Value{ + types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("third"), + types.StringValue("fourth"), + }, + ), + }, + ), + }, + "Set unknown": { + val: types.SetUnknown( + types.StringType, + ), + elementValidators: []validator.Set{ + setvalidator.SizeAtLeast(1), + }, + }, + "Set null": { + val: types.SetNull( + types.StringType, + ), + elementValidators: []validator.Set{ + setvalidator.SizeAtLeast(1), + }, + }, + "Set elements invalid": { + val: types.SetValueMust( + types.SetType{ElemType: types.StringType}, + []attr.Value{ + types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("third"), + types.StringValue("fourth"), + }, + ), + }, + ), + elementValidators: []validator.Set{ + setvalidator.SizeAtLeast(3), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + )), + "Invalid Attribute Value", + "Attribute test[Value([\"first\",\"second\"])] set must contain at least 3 elements, got: 2", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("third"), + types.StringValue("fourth"), + }, + )), + "Invalid Attribute Value", + "Attribute test[Value([\"third\",\"fourth\"])] set must contain at least 3 elements, got: 2", + ), + }, + }, + "Set elements invalid for multiple validator": { + val: types.SetValueMust( + types.SetType{ElemType: types.StringType}, + []attr.Value{ + types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("third"), + types.StringValue("fourth"), + }, + ), + }, + ), + elementValidators: []validator.Set{ + setvalidator.SizeAtLeast(3), + setvalidator.SizeAtLeast(4), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + )), + "Invalid Attribute Value", + "Attribute test[Value([\"first\",\"second\"])] set must contain at least 3 elements, got: 2", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + )), + "Invalid Attribute Value", + "Attribute test[Value([\"first\",\"second\"])] set must contain at least 4 elements, got: 2", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("third"), + types.StringValue("fourth"), + }, + )), + "Invalid Attribute Value", + "Attribute test[Value([\"third\",\"fourth\"])] set must contain at least 3 elements, got: 2", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("third"), + types.StringValue("fourth"), + }, + )), + "Invalid Attribute Value", + "Attribute test[Value([\"third\",\"fourth\"])] set must contain at least 4 elements, got: 2", + ), + }, + }, + "Set elements valid": { + val: types.SetValueMust( + types.SetType{ElemType: types.StringType}, + []attr.Value{ + types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("third"), + types.StringValue("fourth"), + }, + ), + }, + ), + elementValidators: []validator.Set{ + setvalidator.SizeAtLeast(1), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + request := validator.SetRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: testCase.val, + } + response := validator.SetResponse{} + setvalidator.ValueSetsAre(testCase.elementValidators...).ValidateSet(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, testCase.expectedDiagnostics); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/setvalidator/value_strings_are.go b/setvalidator/value_strings_are.go new file mode 100644 index 0000000..11a040a --- /dev/null +++ b/setvalidator/value_strings_are.go @@ -0,0 +1,116 @@ +package setvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// ValueStringsAre returns an validator which ensures that any configured +// String values passes each String validator. +// +// Null (unconfigured) and unknown (known after apply) values are skipped. +func ValueStringsAre(elementValidators ...validator.String) validator.Set { + return valueStringsAreValidator{ + elementValidators: elementValidators, + } +} + +var _ validator.Set = valueStringsAreValidator{} + +// valueStringsAreValidator validates that each set member validates against each of the value validators. +type valueStringsAreValidator struct { + elementValidators []validator.String +} + +// Description describes the validation in plain text formatting. +func (v valueStringsAreValidator) Description(ctx context.Context) string { + var descriptions []string + + for _, elementValidator := range v.elementValidators { + descriptions = append(descriptions, elementValidator.Description(ctx)) + } + + return fmt.Sprintf("element value must satisfy all validations: %s", strings.Join(descriptions, " + ")) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (v valueStringsAreValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +// ValidateSet performs the validation. +func (v valueStringsAreValidator) ValidateSet(ctx context.Context, req validator.SetRequest, resp *validator.SetResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + _, ok := req.ConfigValue.ElementType(ctx).(types.StringTypable) + + if !ok { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Validator for Element Type", + "While performing schema-based validation, an unexpected error occurred. "+ + "The attribute declares a String values validator, however its values do not implement types.StringType or the types.StringTypable interface for custom String types. "+ + "Use the appropriate values validator that matches the element type. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Path: %s\n", req.Path.String())+ + fmt.Sprintf("Element Type: %T\n", req.ConfigValue.ElementType(ctx)), + ) + + return + } + + for _, element := range req.ConfigValue.Elements() { + elementPath := req.Path.AtSetValue(element) + + elementValuable, ok := element.(types.StringValuable) + + // The check above should have prevented this, but raise an error + // instead of a type assertion panic or skipping the element. Any issue + // here likely indicates something wrong in the framework itself. + if !ok { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Validator for Element Value", + "While performing schema-based validation, an unexpected error occurred. "+ + "The attribute declares a String values validator, however its values do not implement types.StringType or the types.StringTypable interface for custom String types. "+ + "This is likely an issue with terraform-plugin-framework and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Path: %s\n", req.Path.String())+ + fmt.Sprintf("Element Type: %T\n", req.ConfigValue.ElementType(ctx))+ + fmt.Sprintf("Element Value Type: %T\n", element), + ) + + return + } + + elementValue, diags := elementValuable.ToStringValue(ctx) + + resp.Diagnostics.Append(diags...) + + // Only return early if the new diagnostics indicate an issue since + // it likely will be the same for all elements. + if diags.HasError() { + return + } + + elementReq := validator.StringRequest{ + Path: elementPath, + PathExpression: elementPath.Expression(), + ConfigValue: elementValue, + Config: req.Config, + } + + for _, elementValidator := range v.elementValidators { + elementResp := &validator.StringResponse{} + + elementValidator.ValidateString(ctx, elementReq, elementResp) + + resp.Diagnostics.Append(elementResp.Diagnostics...) + } + } +} diff --git a/setvalidator/value_strings_are_example_test.go b/setvalidator/value_strings_are_example_test.go new file mode 100644 index 0000000..e9df73f --- /dev/null +++ b/setvalidator/value_strings_are_example_test.go @@ -0,0 +1,25 @@ +package setvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ExampleValueStringsAre() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.SetAttribute{ + ElementType: types.StringType, + Required: true, + Validators: []validator.Set{ + // Validate this Set must contain string values which are at least 3 characters. + setvalidator.ValueStringsAre(stringvalidator.LengthAtLeast(3)), + }, + }, + }, + } +} diff --git a/setvalidator/value_strings_are_test.go b/setvalidator/value_strings_are_test.go new file mode 100644 index 0000000..2a1e921 --- /dev/null +++ b/setvalidator/value_strings_are_test.go @@ -0,0 +1,143 @@ +package setvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" +) + +func TestValueStringsAreValidatorValidateSet(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + val types.Set + elementValidators []validator.String + expectedDiagnostics diag.Diagnostics + }{ + "no element validators": { + val: types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + }, + "Set unknown": { + val: types.SetUnknown( + types.StringType, + ), + elementValidators: []validator.String{ + stringvalidator.LengthAtLeast(6), + }, + }, + "Set null": { + val: types.SetNull( + types.StringType, + ), + elementValidators: []validator.String{ + stringvalidator.LengthAtLeast(6), + }, + }, + "Set elements invalid": { + val: types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + elementValidators: []validator.String{ + stringvalidator.LengthAtLeast(7), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.StringValue("first")), + "Invalid Attribute Value Length", + "Attribute test[Value(\"first\")] string length must be at least 7, got: 5", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.StringValue("second")), + "Invalid Attribute Value Length", + "Attribute test[Value(\"second\")] string length must be at least 7, got: 6", + ), + }, + }, + "Set elements invalid for multiple validator": { + val: types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + elementValidators: []validator.String{ + stringvalidator.LengthAtLeast(7), + stringvalidator.LengthAtLeast(8), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.StringValue("first")), + "Invalid Attribute Value Length", + "Attribute test[Value(\"first\")] string length must be at least 7, got: 5", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.StringValue("first")), + "Invalid Attribute Value Length", + "Attribute test[Value(\"first\")] string length must be at least 8, got: 5", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.StringValue("second")), + "Invalid Attribute Value Length", + "Attribute test[Value(\"second\")] string length must be at least 7, got: 6", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.StringValue("second")), + "Invalid Attribute Value Length", + "Attribute test[Value(\"second\")] string length must be at least 8, got: 6", + ), + }, + }, + "Set elements valid": { + val: types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + elementValidators: []validator.String{ + stringvalidator.LengthAtLeast(5), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + request := validator.SetRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: testCase.val, + } + response := validator.SetResponse{} + setvalidator.ValueStringsAre(testCase.elementValidators...).ValidateSet(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, testCase.expectedDiagnostics); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/setvalidator/values_are.go b/setvalidator/values_are.go deleted file mode 100644 index c7e5327..0000000 --- a/setvalidator/values_are.go +++ /dev/null @@ -1,66 +0,0 @@ -package setvalidator - -import ( - "context" - "fmt" - "strings" - - "github.com/hashicorp/terraform-plugin-framework/tfsdk" -) - -var _ tfsdk.AttributeValidator = valuesAreValidator{} - -// valuesAreValidator validates that each set member validates against each of the value validators. -type valuesAreValidator struct { - valueValidators []tfsdk.AttributeValidator -} - -// Description describes the validation in plain text formatting. -func (v valuesAreValidator) Description(ctx context.Context) string { - var descriptions []string - for _, validator := range v.valueValidators { - descriptions = append(descriptions, validator.Description(ctx)) - } - - return fmt.Sprintf("value must satisfy all validations: %s", strings.Join(descriptions, " + ")) -} - -// MarkdownDescription describes the validation in Markdown formatting. -func (v valuesAreValidator) MarkdownDescription(ctx context.Context) string { - return v.Description(ctx) -} - -// Validate performs the validation. -func (v valuesAreValidator) Validate(ctx context.Context, req tfsdk.ValidateAttributeRequest, resp *tfsdk.ValidateAttributeResponse) { - elems, ok := validateSet(ctx, req, resp) - if !ok { - return - } - - for _, elem := range elems { - attrPath := req.AttributePath.AtSetValue(elem) - request := tfsdk.ValidateAttributeRequest{ - AttributePath: attrPath, - AttributePathExpression: attrPath.Expression(), - AttributeConfig: elem, - Config: req.Config, - } - - for _, validator := range v.valueValidators { - validator.Validate(ctx, request, resp) - } - } -} - -// ValuesAre returns an AttributeValidator which ensures that any configured -// attribute value: -// -// - Is a Set. -// - Contains Set elements, each of which validate against each value validator. -// -// Null (unconfigured) and unknown (known after apply) values are skipped. -func ValuesAre(valueValidators ...tfsdk.AttributeValidator) tfsdk.AttributeValidator { - return valuesAreValidator{ - valueValidators: valueValidators, - } -} diff --git a/setvalidator/values_are_example_test.go b/setvalidator/values_are_example_test.go deleted file mode 100644 index 5572fe3..0000000 --- a/setvalidator/values_are_example_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package setvalidator_test - -import ( - "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" - "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" -) - -func ExampleValuesAre() { - // Used within a GetSchema method of a DataSource, Provider, or Resource - _ = tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "example_attr": { - Required: true, - Type: types.SetType{ - ElemType: types.StringType, - }, - Validators: []tfsdk.AttributeValidator{ - // Validate this set must contain string values which are at least 3 characters. - setvalidator.ValuesAre(stringvalidator.LengthAtLeast(3)), - }, - }, - }, - } -} diff --git a/setvalidator/values_are_test.go b/setvalidator/values_are_test.go deleted file mode 100644 index 07b1935..0000000 --- a/setvalidator/values_are_test.go +++ /dev/null @@ -1,119 +0,0 @@ -package setvalidator - -import ( - "context" - "testing" - - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" - - "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" - "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" -) - -func TestValuesAreValidator(t *testing.T) { - t.Parallel() - - type testCase struct { - val attr.Value - valuesAreValidators []tfsdk.AttributeValidator - expectError bool - } - tests := map[string]testCase{ - "not Set": { - val: types.MapValueMust( - types.StringType, - map[string]attr.Value{}, - ), - expectError: true, - }, - "Set unknown": { - val: types.SetUnknown( - types.StringType, - ), - expectError: false, - }, - "Set null": { - val: types.SetNull( - types.StringType, - ), - expectError: false, - }, - "Set elems invalid": { - val: types.SetValueMust( - types.StringType, - []attr.Value{ - types.StringValue("first"), - types.StringValue("second"), - }, - ), - valuesAreValidators: []tfsdk.AttributeValidator{ - stringvalidator.LengthAtLeast(6), - }, - expectError: true, - }, - "Set elems invalid for second validator": { - val: types.SetValueMust( - types.StringType, - []attr.Value{ - types.StringValue("first"), - types.StringValue("second"), - }, - ), - valuesAreValidators: []tfsdk.AttributeValidator{ - stringvalidator.LengthAtLeast(2), - stringvalidator.LengthAtLeast(6), - }, - expectError: true, - }, - "Set elems wrong type for validator": { - val: types.SetValueMust( - types.StringType, - []attr.Value{ - types.StringValue("first"), - types.StringValue("second"), - }, - ), - valuesAreValidators: []tfsdk.AttributeValidator{ - int64validator.AtLeast(6), - }, - expectError: true, - }, - "Set elems valid": { - val: types.SetValueMust( - types.StringType, - []attr.Value{ - types.StringValue("first"), - types.StringValue("second"), - }, - ), - valuesAreValidators: []tfsdk.AttributeValidator{ - stringvalidator.LengthAtLeast(5), - }, - expectError: false, - }, - } - - for name, test := range tests { - name, test := name, test - t.Run(name, func(t *testing.T) { - request := tfsdk.ValidateAttributeRequest{ - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - AttributeConfig: test.val, - } - response := tfsdk.ValidateAttributeResponse{} - ValuesAre(test.valuesAreValidators...).Validate(context.TODO(), request, &response) - - if !response.Diagnostics.HasError() && test.expectError { - t.Fatal("expected error, got no error") - } - - if response.Diagnostics.HasError() && !test.expectError { - t.Fatalf("got unexpected error: %s", response.Diagnostics) - } - }) - } -} diff --git a/stringvalidator/acceptable_strings_validator.go b/stringvalidator/acceptable_strings_validator.go deleted file mode 100644 index e0a302b..0000000 --- a/stringvalidator/acceptable_strings_validator.go +++ /dev/null @@ -1,56 +0,0 @@ -package stringvalidator - -import ( - "context" - "fmt" - "strings" - - "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" -) - -// acceptableStringsCaseInsensitiveAttributeValidator is the underlying struct implementing OneOf and NoneOf. -type acceptableStringsCaseInsensitiveAttributeValidator struct { - acceptableStrings []string - shouldMatch bool -} - -var _ tfsdk.AttributeValidator = (*acceptableStringsCaseInsensitiveAttributeValidator)(nil) - -func (av *acceptableStringsCaseInsensitiveAttributeValidator) Description(ctx context.Context) string { - return av.MarkdownDescription(ctx) -} - -func (av *acceptableStringsCaseInsensitiveAttributeValidator) MarkdownDescription(_ context.Context) string { - if av.shouldMatch { - return fmt.Sprintf("String must match one of: %q", av.acceptableStrings) - } else { - return fmt.Sprintf("String must match none of: %q", av.acceptableStrings) - } -} - -func (av *acceptableStringsCaseInsensitiveAttributeValidator) Validate(ctx context.Context, req tfsdk.ValidateAttributeRequest, res *tfsdk.ValidateAttributeResponse) { - value, ok := validateString(ctx, req, res) - if !ok { - return - } - - if av.shouldMatch && !av.isAcceptableValue(value) || //< EITHER should match but it does not - !av.shouldMatch && av.isAcceptableValue(value) { //< OR should not match but it does - res.Diagnostics.Append(validatordiag.InvalidAttributeValueMatchDiagnostic( - req.AttributePath, - av.Description(ctx), - value, - )) - } -} - -func (av *acceptableStringsCaseInsensitiveAttributeValidator) isAcceptableValue(v string) bool { - for _, acceptableV := range av.acceptableStrings { - if strings.EqualFold(v, acceptableV) { - return true - } - } - - return false -} diff --git a/stringvalidator/all.go b/stringvalidator/all.go new file mode 100644 index 0000000..661b8e0 --- /dev/null +++ b/stringvalidator/all.go @@ -0,0 +1,54 @@ +package stringvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// All returns a validator which ensures that any configured attribute value +// 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 ...validator.String) validator.String { + return allValidator{ + validators: validators, + } +} + +var _ validator.String = allValidator{} + +// allValidator implements the validator. +type allValidator struct { + validators []validator.String +} + +// 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) +} + +// ValidateString performs the validation. +func (v allValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { + for _, subValidator := range v.validators { + validateResp := &validator.StringResponse{} + + subValidator.ValidateString(ctx, req, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) + } +} diff --git a/stringvalidator/all_example_test.go b/stringvalidator/all_example_test.go new file mode 100644 index 0000000..52968e0 --- /dev/null +++ b/stringvalidator/all_example_test.go @@ -0,0 +1,30 @@ +package stringvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleAll() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.StringAttribute{ + Required: true, + Validators: []validator.String{ + // Validate this String value must either be: + // - "one" + // - Length at least 4 characters, but not "three" + stringvalidator.Any( + stringvalidator.OneOf("one"), + stringvalidator.All( + stringvalidator.LengthAtLeast(4), + stringvalidator.NoneOf("three"), + ), + ), + }, + }, + }, + } +} diff --git a/stringvalidator/all_test.go b/stringvalidator/all_test.go new file mode 100644 index 0000000..518e584 --- /dev/null +++ b/stringvalidator/all_test.go @@ -0,0 +1,70 @@ +package stringvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" +) + +func TestAllValidatorValidateString(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.String + validators []validator.String + expected diag.Diagnostics + } + tests := map[string]testCase{ + "invalid": { + val: types.StringValue("test"), + validators: []validator.String{ + stringvalidator.LengthAtLeast(5), + stringvalidator.LengthAtLeast(6), + }, + expected: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid Attribute Value Length", + "Attribute test string length must be at least 5, got: 4", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid Attribute Value Length", + "Attribute test string length must be at least 6, got: 4", + ), + }, + }, + "valid": { + val: types.StringValue("test"), + validators: []validator.String{ + stringvalidator.LengthAtLeast(0), + stringvalidator.LengthAtLeast(1), + }, + expected: nil, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + request := validator.StringRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + } + response := validator.StringResponse{} + stringvalidator.All(test.validators...).ValidateString(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, test.expected); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/stringvalidator/also_requires.go b/stringvalidator/also_requires.go new file mode 100644 index 0000000..1204b78 --- /dev/null +++ b/stringvalidator/also_requires.go @@ -0,0 +1,23 @@ +package stringvalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// AlsoRequires checks that a set of path.Expression has a non-null value, +// if the current attribute or block also has a non-null value. +// +// This implements the validation logic declaratively within the schema. +// Refer to [datasourcevalidator.RequiredTogether], +// [providervalidator.RequiredTogether], or [resourcevalidator.RequiredTogether] +// for declaring this type of validation outside the schema definition. +// +// Relative path.Expression will be resolved using the attribute or block +// being validated. +func AlsoRequires(expressions ...path.Expression) validator.String { + return schemavalidator.AlsoRequiresValidator{ + PathExpressions: expressions, + } +} diff --git a/stringvalidator/also_requires_example_test.go b/stringvalidator/also_requires_example_test.go new file mode 100644 index 0000000..7054b6e --- /dev/null +++ b/stringvalidator/also_requires_example_test.go @@ -0,0 +1,28 @@ +package stringvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleAlsoRequires() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.StringAttribute{ + Optional: true, + Validators: []validator.String{ + // Validate this attribute must be configured with other_attr. + stringvalidator.AlsoRequires(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.StringAttribute{ + Optional: true, + }, + }, + } +} diff --git a/stringvalidator/any.go b/stringvalidator/any.go new file mode 100644 index 0000000..189bf3a --- /dev/null +++ b/stringvalidator/any.go @@ -0,0 +1,62 @@ +package stringvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// 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 ...validator.String) validator.String { + return anyValidator{ + validators: validators, + } +} + +var _ validator.String = anyValidator{} + +// anyValidator implements the validator. +type anyValidator struct { + validators []validator.String +} + +// 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) +} + +// ValidateString performs the validation. +func (v anyValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { + for _, subValidator := range v.validators { + validateResp := &validator.StringResponse{} + + subValidator.ValidateString(ctx, req, validateResp) + + if !validateResp.Diagnostics.HasError() { + resp.Diagnostics = validateResp.Diagnostics + + return + } + + resp.Diagnostics.Append(validateResp.Diagnostics...) + } +} diff --git a/stringvalidator/any_example_test.go b/stringvalidator/any_example_test.go new file mode 100644 index 0000000..b972ee6 --- /dev/null +++ b/stringvalidator/any_example_test.go @@ -0,0 +1,27 @@ +package stringvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleAny() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.StringAttribute{ + Required: true, + Validators: []validator.String{ + // Validate this String value must either be: + // - "one" + // - Length at least 4 characters + stringvalidator.Any( + stringvalidator.OneOf("one"), + stringvalidator.LengthAtLeast(4), + ), + }, + }, + }, + } +} diff --git a/stringvalidator/any_test.go b/stringvalidator/any_test.go new file mode 100644 index 0000000..091b0a1 --- /dev/null +++ b/stringvalidator/any_test.go @@ -0,0 +1,81 @@ +package stringvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/internal/testvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" +) + +func TestAnyValidatorValidateString(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.String + validators []validator.String + expected diag.Diagnostics + } + tests := map[string]testCase{ + "invalid": { + val: types.StringValue("one"), + validators: []validator.String{ + stringvalidator.LengthAtLeast(4), + stringvalidator.LengthAtLeast(5), + }, + expected: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid Attribute Value Length", + "Attribute test string length must be at least 4, got: 3", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid Attribute Value Length", + "Attribute test string length must be at least 5, got: 3", + ), + }, + }, + "valid": { + val: types.StringValue("test"), + validators: []validator.String{ + stringvalidator.LengthAtLeast(5), + stringvalidator.LengthAtLeast(3), + }, + expected: diag.Diagnostics{}, + }, + "valid with warning": { + val: types.StringValue("test"), + validators: []validator.String{ + stringvalidator.All(stringvalidator.LengthAtLeast(5), testvalidator.WarningString("failing warning summary", "failing warning details")), + stringvalidator.All(stringvalidator.LengthAtLeast(2), testvalidator.WarningString("passing warning summary", "passing warning details")), + }, + expected: diag.Diagnostics{ + diag.NewWarningDiagnostic("passing warning summary", "passing warning details"), + }, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + request := validator.StringRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + } + response := validator.StringResponse{} + stringvalidator.Any(test.validators...).ValidateString(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, test.expected); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/stringvalidator/any_with_all_warnings.go b/stringvalidator/any_with_all_warnings.go new file mode 100644 index 0000000..48e8c63 --- /dev/null +++ b/stringvalidator/any_with_all_warnings.go @@ -0,0 +1,64 @@ +package stringvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// 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 ...validator.String) validator.String { + return anyWithAllWarningsValidator{ + validators: validators, + } +} + +var _ validator.String = anyWithAllWarningsValidator{} + +// anyWithAllWarningsValidator implements the validator. +type anyWithAllWarningsValidator struct { + validators []validator.String +} + +// 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) +} + +// ValidateString performs the validation. +func (v anyWithAllWarningsValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { + anyValid := false + + for _, subValidator := range v.validators { + validateResp := &validator.StringResponse{} + + subValidator.ValidateString(ctx, req, validateResp) + + if !validateResp.Diagnostics.HasError() { + anyValid = true + } + + resp.Diagnostics.Append(validateResp.Diagnostics...) + } + + if anyValid { + resp.Diagnostics = resp.Diagnostics.Warnings() + } +} diff --git a/stringvalidator/any_with_all_warnings_example_test.go b/stringvalidator/any_with_all_warnings_example_test.go new file mode 100644 index 0000000..039b604 --- /dev/null +++ b/stringvalidator/any_with_all_warnings_example_test.go @@ -0,0 +1,27 @@ +package stringvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleAnyWithAllWarnings() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.StringAttribute{ + Required: true, + Validators: []validator.String{ + // Validate this String value must either be: + // - "one" + // - Length at least 4 characters + stringvalidator.AnyWithAllWarnings( + stringvalidator.OneOf("one"), + stringvalidator.LengthAtLeast(4), + ), + }, + }, + }, + } +} diff --git a/stringvalidator/any_with_all_warnings_test.go b/stringvalidator/any_with_all_warnings_test.go new file mode 100644 index 0000000..f889827 --- /dev/null +++ b/stringvalidator/any_with_all_warnings_test.go @@ -0,0 +1,82 @@ +package stringvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/internal/testvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" +) + +func TestAnyWithAllWarningsValidatorValidateString(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.String + validators []validator.String + expected diag.Diagnostics + } + tests := map[string]testCase{ + "invalid": { + val: types.StringValue("one"), + validators: []validator.String{ + stringvalidator.LengthAtLeast(4), + stringvalidator.LengthAtLeast(5), + }, + expected: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid Attribute Value Length", + "Attribute test string length must be at least 4, got: 3", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid Attribute Value Length", + "Attribute test string length must be at least 5, got: 3", + ), + }, + }, + "valid": { + val: types.StringValue("test"), + validators: []validator.String{ + stringvalidator.LengthAtLeast(5), + stringvalidator.LengthAtLeast(3), + }, + expected: diag.Diagnostics{}, + }, + "valid with warning": { + val: types.StringValue("test"), + validators: []validator.String{ + stringvalidator.All(stringvalidator.LengthAtLeast(5), testvalidator.WarningString("failing warning summary", "failing warning details")), + stringvalidator.All(stringvalidator.LengthAtLeast(2), testvalidator.WarningString("passing warning summary", "passing warning details")), + }, + expected: diag.Diagnostics{ + diag.NewWarningDiagnostic("failing warning summary", "failing warning details"), + diag.NewWarningDiagnostic("passing warning summary", "passing warning details"), + }, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + request := validator.StringRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + } + response := validator.StringResponse{} + stringvalidator.AnyWithAllWarnings(test.validators...).ValidateString(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, test.expected); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/stringvalidator/at_least_one_of.go b/stringvalidator/at_least_one_of.go new file mode 100644 index 0000000..e731aba --- /dev/null +++ b/stringvalidator/at_least_one_of.go @@ -0,0 +1,24 @@ +package stringvalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// AtLeastOneOf checks that of a set of path.Expression, +// including the attribute this validator is applied to, +// at least one has a non-null value. +// +// This implements the validation logic declaratively within the tfsdk.Schema. +// Refer to [datasourcevalidator.AtLeastOneOf], +// [providervalidator.AtLeastOneOf], or [resourcevalidator.AtLeastOneOf] +// for declaring this type of validation outside the schema definition. +// +// Any relative path.Expression will be resolved using the attribute being +// validated. +func AtLeastOneOf(expressions ...path.Expression) validator.String { + return schemavalidator.AtLeastOneOfValidator{ + PathExpressions: expressions, + } +} diff --git a/stringvalidator/at_least_one_of_example_test.go b/stringvalidator/at_least_one_of_example_test.go new file mode 100644 index 0000000..8af6129 --- /dev/null +++ b/stringvalidator/at_least_one_of_example_test.go @@ -0,0 +1,28 @@ +package stringvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleAtLeastOneOf() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.StringAttribute{ + Optional: true, + Validators: []validator.String{ + // Validate at least this attribute or other_attr should be configured. + stringvalidator.AtLeastOneOf(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.StringAttribute{ + Optional: true, + }, + }, + } +} diff --git a/stringvalidator/conflicts_with.go b/stringvalidator/conflicts_with.go new file mode 100644 index 0000000..2565d1c --- /dev/null +++ b/stringvalidator/conflicts_with.go @@ -0,0 +1,24 @@ +package stringvalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// ConflictsWith checks that a set of path.Expression, +// including the attribute the validator is applied to, +// do not have a value simultaneously. +// +// This implements the validation logic declaratively within the schema. +// Refer to [datasourcevalidator.Conflicting], +// [providervalidator.Conflicting], or [resourcevalidator.Conflicting] +// for declaring this type of validation outside the schema definition. +// +// Relative path.Expression will be resolved using the attribute being +// validated. +func ConflictsWith(expressions ...path.Expression) validator.String { + return schemavalidator.ConflictsWithValidator{ + PathExpressions: expressions, + } +} diff --git a/stringvalidator/conflicts_with_example_test.go b/stringvalidator/conflicts_with_example_test.go new file mode 100644 index 0000000..e8ce7a1 --- /dev/null +++ b/stringvalidator/conflicts_with_example_test.go @@ -0,0 +1,28 @@ +package stringvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleConflictsWith() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.StringAttribute{ + Optional: true, + Validators: []validator.String{ + // Validate this attribute must not be configured with other_attr. + stringvalidator.ConflictsWith(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.StringAttribute{ + Optional: true, + }, + }, + } +} diff --git a/stringvalidator/exactly_one_of.go b/stringvalidator/exactly_one_of.go new file mode 100644 index 0000000..c54565b --- /dev/null +++ b/stringvalidator/exactly_one_of.go @@ -0,0 +1,25 @@ +package stringvalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// ExactlyOneOf checks that of a set of path.Expression, +// including the attribute the validator is applied to, +// one and only one attribute has a value. +// It will also cause a validation error if none are specified. +// +// This implements the validation logic declaratively within the schema. +// Refer to [datasourcevalidator.ExactlyOneOf], +// [providervalidator.ExactlyOneOf], or [resourcevalidator.ExactlyOneOf] +// for declaring this type of validation outside the schema definition. +// +// Relative path.Expression will be resolved using the attribute being +// validated. +func ExactlyOneOf(expressions ...path.Expression) validator.String { + return schemavalidator.ExactlyOneOfValidator{ + PathExpressions: expressions, + } +} diff --git a/stringvalidator/exactly_one_of_example_test.go b/stringvalidator/exactly_one_of_example_test.go new file mode 100644 index 0000000..e6e8b40 --- /dev/null +++ b/stringvalidator/exactly_one_of_example_test.go @@ -0,0 +1,28 @@ +package stringvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleExactlyOneOf() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.StringAttribute{ + Optional: true, + Validators: []validator.String{ + // Validate only this attribute or other_attr is configured. + stringvalidator.ExactlyOneOf(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.StringAttribute{ + Optional: true, + }, + }, + } +} diff --git a/stringvalidator/length_at_least.go b/stringvalidator/length_at_least.go index 7ac3d28..ce938f2 100644 --- a/stringvalidator/length_at_least.go +++ b/stringvalidator/length_at_least.go @@ -4,12 +4,12 @@ import ( "context" "fmt" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" ) -var _ tfsdk.AttributeValidator = lengthAtLeastValidator{} +var _ validator.String = lengthAtLeastValidator{} // stringLenAtLeastValidator validates that a string Attribute's length is at least a certain value. type lengthAtLeastValidator struct { @@ -27,17 +27,17 @@ func (validator lengthAtLeastValidator) MarkdownDescription(ctx context.Context) } // Validate performs the validation. -func (validator lengthAtLeastValidator) Validate(ctx context.Context, request tfsdk.ValidateAttributeRequest, response *tfsdk.ValidateAttributeResponse) { - s, ok := validateString(ctx, request, response) - - if !ok { +func (v lengthAtLeastValidator) ValidateString(ctx context.Context, request validator.StringRequest, response *validator.StringResponse) { + if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() { return } - if l := len(s); l < validator.minLength { + value := request.ConfigValue.ValueString() + + if l := len(value); l < v.minLength { response.Diagnostics.Append(validatordiag.InvalidAttributeValueLengthDiagnostic( - request.AttributePath, - validator.Description(ctx), + request.Path, + v.Description(ctx), fmt.Sprintf("%d", l), )) @@ -52,7 +52,7 @@ func (validator lengthAtLeastValidator) Validate(ctx context.Context, request tf // - Is of length greater than or equal to the given minimum. // // Null (unconfigured) and unknown (known after apply) values are skipped. -func LengthAtLeast(minLength int) tfsdk.AttributeValidator { +func LengthAtLeast(minLength int) validator.String { if minLength < 0 { return nil } diff --git a/stringvalidator/length_at_least_example_test.go b/stringvalidator/length_at_least_example_test.go index 6fb1a8e..8e5ac06 100644 --- a/stringvalidator/length_at_least_example_test.go +++ b/stringvalidator/length_at_least_example_test.go @@ -2,18 +2,17 @@ package stringvalidator_test import ( "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" ) func ExampleLengthAtLeast() { - // Used within a GetSchema method of a DataSource, Provider, or Resource - _ = tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "example_attr": { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.StringAttribute{ Required: true, - Type: types.StringType, - Validators: []tfsdk.AttributeValidator{ + Validators: []validator.String{ // Validate string value length must be at least 3 characters. stringvalidator.LengthAtLeast(3), }, diff --git a/stringvalidator/length_at_least_test.go b/stringvalidator/length_at_least_test.go index dfa50b7..8a9d297 100644 --- a/stringvalidator/length_at_least_test.go +++ b/stringvalidator/length_at_least_test.go @@ -4,9 +4,8 @@ import ( "context" "testing" - "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" @@ -16,15 +15,11 @@ func TestLengthAtLeastValidator(t *testing.T) { t.Parallel() type testCase struct { - val attr.Value + val types.String minLength int expectError bool } tests := map[string]testCase{ - "not a String": { - val: types.BoolValue(true), - expectError: true, - }, "unknown String": { val: types.StringUnknown(), minLength: 1, @@ -47,13 +42,13 @@ func TestLengthAtLeastValidator(t *testing.T) { for name, test := range tests { name, test := name, test t.Run(name, func(t *testing.T) { - request := tfsdk.ValidateAttributeRequest{ - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - AttributeConfig: test.val, + request := validator.StringRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, } - response := tfsdk.ValidateAttributeResponse{} - stringvalidator.LengthAtLeast(test.minLength).Validate(context.TODO(), request, &response) + response := validator.StringResponse{} + stringvalidator.LengthAtLeast(test.minLength).ValidateString(context.TODO(), request, &response) if !response.Diagnostics.HasError() && test.expectError { t.Fatal("expected error, got no error") diff --git a/stringvalidator/length_at_most.go b/stringvalidator/length_at_most.go index 63623ff..c57e82f 100644 --- a/stringvalidator/length_at_most.go +++ b/stringvalidator/length_at_most.go @@ -4,12 +4,11 @@ import ( "context" "fmt" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" ) -var _ tfsdk.AttributeValidator = lengthAtMostValidator{} +var _ validator.String = lengthAtMostValidator{} // lengthAtMostValidator validates that a string Attribute's length is at most a certain value. type lengthAtMostValidator struct { @@ -27,17 +26,17 @@ func (validator lengthAtMostValidator) MarkdownDescription(ctx context.Context) } // Validate performs the validation. -func (validator lengthAtMostValidator) Validate(ctx context.Context, request tfsdk.ValidateAttributeRequest, response *tfsdk.ValidateAttributeResponse) { - s, ok := validateString(ctx, request, response) - - if !ok { +func (v lengthAtMostValidator) ValidateString(ctx context.Context, request validator.StringRequest, response *validator.StringResponse) { + if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() { return } - if l := len(s); l > validator.maxLength { + value := request.ConfigValue.ValueString() + + if l := len(value); l > v.maxLength { response.Diagnostics.Append(validatordiag.InvalidAttributeValueLengthDiagnostic( - request.AttributePath, - validator.Description(ctx), + request.Path, + v.Description(ctx), fmt.Sprintf("%d", l), )) @@ -52,7 +51,7 @@ func (validator lengthAtMostValidator) Validate(ctx context.Context, request tfs // - Is of length less than or equal to the given maximum. // // Null (unconfigured) and unknown (known after apply) values are skipped. -func LengthAtMost(maxLength int) tfsdk.AttributeValidator { +func LengthAtMost(maxLength int) validator.String { if maxLength < 0 { return nil } diff --git a/stringvalidator/length_at_most_example_test.go b/stringvalidator/length_at_most_example_test.go index 28cbb72..cd06281 100644 --- a/stringvalidator/length_at_most_example_test.go +++ b/stringvalidator/length_at_most_example_test.go @@ -2,18 +2,17 @@ package stringvalidator_test import ( "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" ) func ExampleLengthAtMost() { - // Used within a GetSchema method of a DataSource, Provider, or Resource - _ = tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "example_attr": { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.StringAttribute{ Required: true, - Type: types.StringType, - Validators: []tfsdk.AttributeValidator{ + Validators: []validator.String{ // Validate string value length must be at most 256 characters. stringvalidator.LengthAtMost(256), }, diff --git a/stringvalidator/length_at_most_test.go b/stringvalidator/length_at_most_test.go index 1bfe252..07f75bd 100644 --- a/stringvalidator/length_at_most_test.go +++ b/stringvalidator/length_at_most_test.go @@ -4,9 +4,8 @@ import ( "context" "testing" - "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" @@ -16,15 +15,11 @@ func TestLengthAtMostValidator(t *testing.T) { t.Parallel() type testCase struct { - val attr.Value + val types.String maxLength int expectError bool } tests := map[string]testCase{ - "not a String": { - val: types.BoolValue(true), - expectError: true, - }, "unknown String": { val: types.StringUnknown(), maxLength: 1, @@ -47,13 +42,13 @@ func TestLengthAtMostValidator(t *testing.T) { for name, test := range tests { name, test := name, test t.Run(name, func(t *testing.T) { - request := tfsdk.ValidateAttributeRequest{ - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - AttributeConfig: test.val, + request := validator.StringRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, } - response := tfsdk.ValidateAttributeResponse{} - stringvalidator.LengthAtMost(test.maxLength).Validate(context.TODO(), request, &response) + response := validator.StringResponse{} + stringvalidator.LengthAtMost(test.maxLength).ValidateString(context.TODO(), request, &response) if !response.Diagnostics.HasError() && test.expectError { t.Fatal("expected error, got no error") diff --git a/stringvalidator/length_between.go b/stringvalidator/length_between.go index 5edec01..e2d3810 100644 --- a/stringvalidator/length_between.go +++ b/stringvalidator/length_between.go @@ -5,10 +5,10 @@ import ( "fmt" "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" ) -var _ tfsdk.AttributeValidator = lengthBetweenValidator{} +var _ validator.String = lengthBetweenValidator{} // stringLenBetweenValidator validates that a string Attribute's length is in a range. type lengthBetweenValidator struct { @@ -26,17 +26,17 @@ func (validator lengthBetweenValidator) MarkdownDescription(ctx context.Context) } // Validate performs the validation. -func (validator lengthBetweenValidator) Validate(ctx context.Context, request tfsdk.ValidateAttributeRequest, response *tfsdk.ValidateAttributeResponse) { - s, ok := validateString(ctx, request, response) - - if !ok { +func (v lengthBetweenValidator) ValidateString(ctx context.Context, request validator.StringRequest, response *validator.StringResponse) { + if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() { return } - if l := len(s); l < validator.minLength || l > validator.maxLength { + value := request.ConfigValue.ValueString() + + if l := len(value); l < v.minLength || l > v.maxLength { response.Diagnostics.Append(validatordiag.InvalidAttributeValueLengthDiagnostic( - request.AttributePath, - validator.Description(ctx), + request.Path, + v.Description(ctx), fmt.Sprintf("%d", l), )) @@ -51,7 +51,7 @@ func (validator lengthBetweenValidator) Validate(ctx context.Context, request tf // - Is of length greater than the given minimum and less than the given maximum. // // Null (unconfigured) and unknown (known after apply) values are skipped. -func LengthBetween(minLength, maxLength int) tfsdk.AttributeValidator { +func LengthBetween(minLength, maxLength int) validator.String { if minLength < 0 || maxLength < 0 || minLength > maxLength { return nil } diff --git a/stringvalidator/length_between_example_test.go b/stringvalidator/length_between_example_test.go index 12a9541..a4a3914 100644 --- a/stringvalidator/length_between_example_test.go +++ b/stringvalidator/length_between_example_test.go @@ -2,18 +2,17 @@ package stringvalidator_test import ( "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" ) func ExampleLengthBetween() { - // Used within a GetSchema method of a DataSource, Provider, or Resource - _ = tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "example_attr": { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.StringAttribute{ Required: true, - Type: types.StringType, - Validators: []tfsdk.AttributeValidator{ + Validators: []validator.String{ // Validate string value length must be at least 3 and at most 256 characters. stringvalidator.LengthBetween(3, 256), }, diff --git a/stringvalidator/length_between_test.go b/stringvalidator/length_between_test.go index 757d0c3..19ba56d 100644 --- a/stringvalidator/length_between_test.go +++ b/stringvalidator/length_between_test.go @@ -4,9 +4,8 @@ import ( "context" "testing" - "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" @@ -16,16 +15,12 @@ func TestLengthBetweenValidator(t *testing.T) { t.Parallel() type testCase struct { - val attr.Value + val types.String minLength int maxLength int expectError bool } tests := map[string]testCase{ - "not a String": { - val: types.BoolValue(true), - expectError: true, - }, "unknown String": { val: types.StringUnknown(), minLength: 1, @@ -58,13 +53,13 @@ func TestLengthBetweenValidator(t *testing.T) { for name, test := range tests { name, test := name, test t.Run(name, func(t *testing.T) { - request := tfsdk.ValidateAttributeRequest{ - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - AttributeConfig: test.val, + request := validator.StringRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, } - response := tfsdk.ValidateAttributeResponse{} - stringvalidator.LengthBetween(test.minLength, test.maxLength).Validate(context.TODO(), request, &response) + response := validator.StringResponse{} + stringvalidator.LengthBetween(test.minLength, test.maxLength).ValidateString(context.TODO(), request, &response) if !response.Diagnostics.HasError() && test.expectError { t.Fatal("expected error, got no error") diff --git a/stringvalidator/none_of.go b/stringvalidator/none_of.go index 0c4f9a5..bdd547c 100644 --- a/stringvalidator/none_of.go +++ b/stringvalidator/none_of.go @@ -1,29 +1,62 @@ package stringvalidator import ( - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" - "github.com/hashicorp/terraform-plugin-framework-validators/internal/primitivevalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" ) -// NoneOf checks that the string held in the attribute -// is none of the given `unacceptableStrings`. -func NoneOf(unacceptableStrings ...string) tfsdk.AttributeValidator { - unacceptableStringValues := make([]attr.Value, 0, len(unacceptableStrings)) - for _, s := range unacceptableStrings { - unacceptableStringValues = append(unacceptableStringValues, types.StringValue(s)) +var _ validator.String = noneOfValidator{} + +// noneOfValidator validates that the value does not match one of the values. +type noneOfValidator struct { + values []types.String +} + +func (v noneOfValidator) Description(ctx context.Context) string { + return v.MarkdownDescription(ctx) +} + +func (v noneOfValidator) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("value must be none of: %q", v.values) +} + +func (v noneOfValidator) ValidateString(ctx context.Context, request validator.StringRequest, response *validator.StringResponse) { + if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() { + return } - return primitivevalidator.NoneOf(unacceptableStringValues...) + value := request.ConfigValue + + for _, otherValue := range v.values { + if !value.Equal(otherValue) { + continue + } + + response.Diagnostics.Append(validatordiag.InvalidAttributeValueMatchDiagnostic( + request.Path, + v.Description(ctx), + value.String(), + )) + + break + } } -// NoneOfCaseInsensitive checks that the string held in the attribute -// is none of the given `unacceptableStrings`, irrespective of case sensitivity. -func NoneOfCaseInsensitive(unacceptableStrings ...string) tfsdk.AttributeValidator { - return &acceptableStringsCaseInsensitiveAttributeValidator{ - unacceptableStrings, - false, +// NoneOf checks that the String held in the attribute +// is none of the given `values`. +func NoneOf(values ...string) validator.String { + frameworkValues := make([]types.String, 0, len(values)) + + for _, value := range values { + frameworkValues = append(frameworkValues, types.StringValue(value)) + } + + return noneOfValidator{ + values: frameworkValues, } } diff --git a/stringvalidator/none_of_case_insensitive.go b/stringvalidator/none_of_case_insensitive.go new file mode 100644 index 0000000..461944d --- /dev/null +++ b/stringvalidator/none_of_case_insensitive.go @@ -0,0 +1,61 @@ +package stringvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" +) + +var _ validator.String = noneOfCaseInsensitiveValidator{} + +// noneOfCaseInsensitiveValidator validates that the value matches one of expected values. +type noneOfCaseInsensitiveValidator struct { + values []types.String +} + +func (v noneOfCaseInsensitiveValidator) Description(ctx context.Context) string { + return v.MarkdownDescription(ctx) +} + +func (v noneOfCaseInsensitiveValidator) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("value must be none of: %q", v.values) +} + +func (v noneOfCaseInsensitiveValidator) ValidateString(ctx context.Context, request validator.StringRequest, response *validator.StringResponse) { + if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() { + return + } + + value := request.ConfigValue + + for _, otherValue := range v.values { + if strings.EqualFold(value.ValueString(), otherValue.ValueString()) { + response.Diagnostics.Append(validatordiag.InvalidAttributeValueMatchDiagnostic( + request.Path, + v.Description(ctx), + value.String(), + )) + + return + } + } +} + +// NoneOfCaseInsensitive checks that the String held in the attribute +// is none of the given `values`. +func NoneOfCaseInsensitive(values ...string) validator.String { + frameworkValues := make([]types.String, 0, len(values)) + + for _, value := range values { + frameworkValues = append(frameworkValues, types.StringValue(value)) + } + + return noneOfCaseInsensitiveValidator{ + values: frameworkValues, + } +} diff --git a/stringvalidator/none_of_case_insensitive_test.go b/stringvalidator/none_of_case_insensitive_test.go new file mode 100644 index 0000000..a3580f7 --- /dev/null +++ b/stringvalidator/none_of_case_insensitive_test.go @@ -0,0 +1,91 @@ +package stringvalidator_test + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestNoneOfCaseInsensitiveValidator(t *testing.T) { + t.Parallel() + + type testCase struct { + in types.String + validator validator.String + expErrors int + } + + testCases := map[string]testCase{ + "simple-match": { + in: types.StringValue("foo"), + validator: stringvalidator.NoneOfCaseInsensitive( + "foo", + "bar", + "baz", + ), + expErrors: 1, + }, + "simple-match-case-insensitive": { + in: types.StringValue("foo"), + validator: stringvalidator.NoneOfCaseInsensitive( + "FOO", + "bar", + "baz", + ), + expErrors: 1, + }, + "simple-mismatch": { + in: types.StringValue("foz"), + validator: stringvalidator.NoneOfCaseInsensitive( + "foo", + "bar", + "baz", + ), + expErrors: 0, + }, + "skip-validation-on-null": { + in: types.StringNull(), + validator: stringvalidator.NoneOfCaseInsensitive( + "foo", + "bar", + "baz", + ), + expErrors: 0, + }, + "skip-validation-on-unknown": { + in: types.StringUnknown(), + validator: stringvalidator.NoneOfCaseInsensitive( + "foo", + "bar", + "baz", + ), + expErrors: 0, + }, + } + + for name, test := range testCases { + name, test := name, test + t.Run(name, func(t *testing.T) { + req := validator.StringRequest{ + ConfigValue: test.in, + } + res := validator.StringResponse{} + test.validator.ValidateString(context.TODO(), req, &res) + + if test.expErrors > 0 && !res.Diagnostics.HasError() { + t.Fatalf("expected %d error(s), got none", test.expErrors) + } + + if test.expErrors > 0 && test.expErrors != res.Diagnostics.ErrorsCount() { + t.Fatalf("expected %d error(s), got %d: %v", test.expErrors, res.Diagnostics.ErrorsCount(), res.Diagnostics) + } + + if test.expErrors == 0 && res.Diagnostics.HasError() { + t.Fatalf("expected no error(s), got %d: %v", res.Diagnostics.ErrorsCount(), res.Diagnostics) + } + }) + } +} diff --git a/stringvalidator/none_of_example_test.go b/stringvalidator/none_of_example_test.go index c23f6d6..0dc734c 100644 --- a/stringvalidator/none_of_example_test.go +++ b/stringvalidator/none_of_example_test.go @@ -2,18 +2,17 @@ package stringvalidator_test import ( "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" ) func ExampleNoneOf() { - // Used within a GetSchema method of a DataSource, Provider, or Resource - _ = tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "example_attr": { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.StringAttribute{ Required: true, - Type: types.StringType, - Validators: []tfsdk.AttributeValidator{ + Validators: []validator.String{ // Validate string value must not be "one", "two", or "three" stringvalidator.NoneOf([]string{"one", "two", "three"}...), }, diff --git a/stringvalidator/none_of_test.go b/stringvalidator/none_of_test.go index 44e0518..1f3326d 100644 --- a/stringvalidator/none_of_test.go +++ b/stringvalidator/none_of_test.go @@ -4,8 +4,7 @@ import ( "context" "testing" - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" @@ -15,17 +14,11 @@ func TestNoneOfValidator(t *testing.T) { t.Parallel() type testCase struct { - in attr.Value - validator tfsdk.AttributeValidator + in types.String + validator validator.String expErrors int } - objAttrTypes := map[string]attr.Type{ - "Name": types.StringType, - "Age": types.StringType, - "Address": types.StringType, - } - testCases := map[string]testCase{ "simple-match": { in: types.StringValue("foo"), @@ -54,78 +47,6 @@ func TestNoneOfValidator(t *testing.T) { ), expErrors: 0, }, - "list-not-allowed": { - in: types.ListValueMust( - types.StringType, - []attr.Value{ - types.StringValue("10"), - types.StringValue("20"), - types.StringValue("30"), - }, - ), - validator: stringvalidator.NoneOf( - "10", - "20", - "30", - "40", - "50", - ), - expErrors: 1, - }, - "set-not-allowed": { - in: types.SetValueMust( - types.StringType, - []attr.Value{ - types.StringValue("foo"), - types.StringValue("bar"), - types.StringValue("baz"), - }, - ), - validator: stringvalidator.NoneOf( - "bob", - "alice", - "john", - "foo", - "bar", - "baz", - ), - expErrors: 1, - }, - "map-not-allowed": { - in: types.MapValueMust( - types.StringType, - map[string]attr.Value{ - "one.one": types.StringValue("1.1"), - "ten.twenty": types.StringValue("10.20"), - "five.four": types.StringValue("5.4"), - }, - ), - validator: stringvalidator.NoneOf( - "1.1", - "10.20", - "5.4", - "geronimo", - "bob", - ), - expErrors: 1, - }, - "object-not-allowed": { - in: types.ObjectValueMust( - objAttrTypes, - map[string]attr.Value{ - "Name": types.StringValue("Bob Parr"), - "Age": types.StringValue("40"), - "Address": types.StringValue("1200 Park Avenue Emeryville"), - }, - ), - validator: stringvalidator.NoneOf( - "Bob Parr", - "40", - "1200 Park Avenue Emeryville", - "123", - ), - expErrors: 1, - }, "skip-validation-on-null": { in: types.StringNull(), validator: stringvalidator.NoneOf( @@ -149,170 +70,11 @@ func TestNoneOfValidator(t *testing.T) { for name, test := range testCases { name, test := name, test t.Run(name, func(t *testing.T) { - req := tfsdk.ValidateAttributeRequest{ - AttributeConfig: test.in, - } - res := tfsdk.ValidateAttributeResponse{} - test.validator.Validate(context.TODO(), req, &res) - - if test.expErrors > 0 && !res.Diagnostics.HasError() { - t.Fatalf("expected %d error(s), got none", test.expErrors) - } - - if test.expErrors > 0 && test.expErrors != res.Diagnostics.ErrorsCount() { - t.Fatalf("expected %d error(s), got %d: %v", test.expErrors, res.Diagnostics.ErrorsCount(), res.Diagnostics) - } - - if test.expErrors == 0 && res.Diagnostics.HasError() { - t.Fatalf("expected no error(s), got %d: %v", res.Diagnostics.ErrorsCount(), res.Diagnostics) - } - }) - } -} - -func TestNoneOfCaseInsensitiveValidator(t *testing.T) { - t.Parallel() - - type testCase struct { - in attr.Value - validator tfsdk.AttributeValidator - expErrors int - } - - objAttrTypes := map[string]attr.Type{ - "Name": types.StringType, - "Age": types.StringType, - "Address": types.StringType, - } - - testCases := map[string]testCase{ - "simple-match": { - in: types.StringValue("foo"), - validator: stringvalidator.NoneOfCaseInsensitive( - "foo", - "bar", - "baz", - ), - expErrors: 1, - }, - "simple-match-case-insensitive": { - in: types.StringValue("foo"), - validator: stringvalidator.NoneOfCaseInsensitive( - "FOO", - "bar", - "baz", - ), - expErrors: 1, - }, - "simple-mismatch": { - in: types.StringValue("foz"), - validator: stringvalidator.NoneOfCaseInsensitive( - "foo", - "bar", - "baz", - ), - expErrors: 0, - }, - "list-not-allowed": { - in: types.ListValueMust( - types.StringType, - []attr.Value{ - types.StringValue("10"), - types.StringValue("20"), - types.StringValue("30"), - }, - ), - validator: stringvalidator.NoneOfCaseInsensitive( - "10", - "20", - "30", - "40", - "50", - ), - expErrors: 1, - }, - "set-not-allowed": { - in: types.SetValueMust( - types.StringType, - []attr.Value{ - types.StringValue("foo"), - types.StringValue("bar"), - types.StringValue("baz"), - }, - ), - validator: stringvalidator.NoneOfCaseInsensitive( - "bob", - "alice", - "john", - "foo", - "bar", - "baz", - ), - expErrors: 1, - }, - "map-not-allowed": { - in: types.MapValueMust( - types.StringType, - map[string]attr.Value{ - "one.one": types.StringValue("1.1"), - "ten.twenty": types.StringValue("10.20"), - "five.four": types.StringValue("5.4"), - }, - ), - validator: stringvalidator.NoneOfCaseInsensitive( - "1.1", - "10.20", - "5.4", - "geronimo", - "bob", - ), - expErrors: 1, - }, - "object-not-allowed": { - in: types.ObjectValueMust( - objAttrTypes, - map[string]attr.Value{ - "Name": types.StringValue("Bob Parr"), - "Age": types.StringValue("40"), - "Address": types.StringValue("1200 Park Avenue Emeryville"), - }, - ), - validator: stringvalidator.NoneOfCaseInsensitive( - "Bob Parr", - "40", - "1200 Park Avenue Emeryville", - "123", - ), - expErrors: 1, - }, - "skip-validation-on-null": { - in: types.StringNull(), - validator: stringvalidator.NoneOfCaseInsensitive( - "foo", - "bar", - "baz", - ), - expErrors: 0, - }, - "skip-validation-on-unknown": { - in: types.StringUnknown(), - validator: stringvalidator.NoneOfCaseInsensitive( - "foo", - "bar", - "baz", - ), - expErrors: 0, - }, - } - - for name, test := range testCases { - name, test := name, test - t.Run(name, func(t *testing.T) { - req := tfsdk.ValidateAttributeRequest{ - AttributeConfig: test.in, + req := validator.StringRequest{ + ConfigValue: test.in, } - res := tfsdk.ValidateAttributeResponse{} - test.validator.Validate(context.TODO(), req, &res) + res := validator.StringResponse{} + test.validator.ValidateString(context.TODO(), req, &res) if test.expErrors > 0 && !res.Diagnostics.HasError() { t.Fatalf("expected %d error(s), got none", test.expErrors) diff --git a/stringvalidator/one_of.go b/stringvalidator/one_of.go index 8419fd5..3eef108 100644 --- a/stringvalidator/one_of.go +++ b/stringvalidator/one_of.go @@ -1,28 +1,60 @@ package stringvalidator import ( - "github.com/hashicorp/terraform-plugin-framework-validators/internal/primitivevalidator" - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" ) -// OneOf checks that the string held in the attribute -// is one of the given `acceptableStrings`. -func OneOf(acceptableStrings ...string) tfsdk.AttributeValidator { - acceptableStringValues := make([]attr.Value, 0, len(acceptableStrings)) - for _, s := range acceptableStrings { - acceptableStringValues = append(acceptableStringValues, types.StringValue(s)) +var _ validator.String = oneOfValidator{} + +// oneOfValidator validates that the value matches one of expected values. +type oneOfValidator struct { + values []types.String +} + +func (v oneOfValidator) Description(ctx context.Context) string { + return v.MarkdownDescription(ctx) +} + +func (v oneOfValidator) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("value must be one of: %q", v.values) +} + +func (v oneOfValidator) ValidateString(ctx context.Context, request validator.StringRequest, response *validator.StringResponse) { + if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() { + return + } + + value := request.ConfigValue + + for _, otherValue := range v.values { + if value.Equal(otherValue) { + return + } } - return primitivevalidator.OneOf(acceptableStringValues...) + response.Diagnostics.Append(validatordiag.InvalidAttributeValueMatchDiagnostic( + request.Path, + v.Description(ctx), + value.String(), + )) } -// OneOfCaseInsensitive checks that the string held in the attribute -// is one of the given `acceptableStrings`, irrespective of case sensitivity. -func OneOfCaseInsensitive(acceptableStrings ...string) tfsdk.AttributeValidator { - return &acceptableStringsCaseInsensitiveAttributeValidator{ - acceptableStrings, - true, +// OneOf checks that the String held in the attribute +// is none of the given `values`. +func OneOf(values ...string) validator.String { + frameworkValues := make([]types.String, 0, len(values)) + + for _, value := range values { + frameworkValues = append(frameworkValues, types.StringValue(value)) + } + + return oneOfValidator{ + values: frameworkValues, } } diff --git a/stringvalidator/one_of_case_insensitive.go b/stringvalidator/one_of_case_insensitive.go new file mode 100644 index 0000000..567e5dd --- /dev/null +++ b/stringvalidator/one_of_case_insensitive.go @@ -0,0 +1,61 @@ +package stringvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" +) + +var _ validator.String = oneOfCaseInsensitiveValidator{} + +// oneOfCaseInsensitiveValidator validates that the value matches one of expected values. +type oneOfCaseInsensitiveValidator struct { + values []types.String +} + +func (v oneOfCaseInsensitiveValidator) Description(ctx context.Context) string { + return v.MarkdownDescription(ctx) +} + +func (v oneOfCaseInsensitiveValidator) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("value must be one of: %q", v.values) +} + +func (v oneOfCaseInsensitiveValidator) ValidateString(ctx context.Context, request validator.StringRequest, response *validator.StringResponse) { + if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() { + return + } + + value := request.ConfigValue + + for _, otherValue := range v.values { + if strings.EqualFold(value.ValueString(), otherValue.ValueString()) { + return + } + } + + response.Diagnostics.Append(validatordiag.InvalidAttributeValueMatchDiagnostic( + request.Path, + v.Description(ctx), + value.String(), + )) +} + +// OneOfCaseInsensitive checks that the String held in the attribute +// is none of the given `values`. +func OneOfCaseInsensitive(values ...string) validator.String { + frameworkValues := make([]types.String, 0, len(values)) + + for _, value := range values { + frameworkValues = append(frameworkValues, types.StringValue(value)) + } + + return oneOfCaseInsensitiveValidator{ + values: frameworkValues, + } +} diff --git a/stringvalidator/one_of_case_insensitive_test.go b/stringvalidator/one_of_case_insensitive_test.go new file mode 100644 index 0000000..3a258b7 --- /dev/null +++ b/stringvalidator/one_of_case_insensitive_test.go @@ -0,0 +1,91 @@ +package stringvalidator_test + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestOneOfCaseInsensitiveValidator(t *testing.T) { + t.Parallel() + + type testCase struct { + in types.String + validator validator.String + expErrors int + } + + testCases := map[string]testCase{ + "simple-match": { + in: types.StringValue("foo"), + validator: stringvalidator.OneOfCaseInsensitive( + "foo", + "bar", + "baz", + ), + expErrors: 0, + }, + "simple-match-case-insensitive": { + in: types.StringValue("foo"), + validator: stringvalidator.OneOfCaseInsensitive( + "FOO", + "bar", + "baz", + ), + expErrors: 0, + }, + "simple-mismatch": { + in: types.StringValue("foz"), + validator: stringvalidator.OneOfCaseInsensitive( + "foo", + "bar", + "baz", + ), + expErrors: 1, + }, + "skip-validation-on-null": { + in: types.StringNull(), + validator: stringvalidator.OneOfCaseInsensitive( + "foo", + "bar", + "baz", + ), + expErrors: 0, + }, + "skip-validation-on-unknown": { + in: types.StringUnknown(), + validator: stringvalidator.OneOfCaseInsensitive( + "foo", + "bar", + "baz", + ), + expErrors: 0, + }, + } + + for name, test := range testCases { + name, test := name, test + t.Run(name, func(t *testing.T) { + req := validator.StringRequest{ + ConfigValue: test.in, + } + res := validator.StringResponse{} + test.validator.ValidateString(context.TODO(), req, &res) + + if test.expErrors > 0 && !res.Diagnostics.HasError() { + t.Fatalf("expected %d error(s), got none", test.expErrors) + } + + if test.expErrors > 0 && test.expErrors != res.Diagnostics.ErrorsCount() { + t.Fatalf("expected %d error(s), got %d: %v", test.expErrors, res.Diagnostics.ErrorsCount(), res.Diagnostics) + } + + if test.expErrors == 0 && res.Diagnostics.HasError() { + t.Fatalf("expected no error(s), got %d: %v", res.Diagnostics.ErrorsCount(), res.Diagnostics) + } + }) + } +} diff --git a/stringvalidator/one_of_example_test.go b/stringvalidator/one_of_example_test.go index c810ac9..914ad8c 100644 --- a/stringvalidator/one_of_example_test.go +++ b/stringvalidator/one_of_example_test.go @@ -2,18 +2,17 @@ package stringvalidator_test import ( "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" ) func ExampleOneOf() { - // Used within a GetSchema method of a DataSource, Provider, or Resource - _ = tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "example_attr": { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.StringAttribute{ Required: true, - Type: types.StringType, - Validators: []tfsdk.AttributeValidator{ + Validators: []validator.String{ // Validate string value must be "one", "two", or "three" stringvalidator.OneOf([]string{"one", "two", "three"}...), }, diff --git a/stringvalidator/one_of_test.go b/stringvalidator/one_of_test.go index 0ef780a..0e33ef8 100644 --- a/stringvalidator/one_of_test.go +++ b/stringvalidator/one_of_test.go @@ -4,8 +4,7 @@ import ( "context" "testing" - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" @@ -15,17 +14,11 @@ func TestOneOfValidator(t *testing.T) { t.Parallel() type testCase struct { - in attr.Value - validator tfsdk.AttributeValidator + in types.String + validator validator.String expErrors int } - objAttrTypes := map[string]attr.Type{ - "Name": types.StringType, - "Age": types.StringType, - "Address": types.StringType, - } - testCases := map[string]testCase{ "simple-match": { in: types.StringValue("foo"), @@ -54,78 +47,6 @@ func TestOneOfValidator(t *testing.T) { ), expErrors: 1, }, - "list-not-allowed": { - in: types.ListValueMust( - types.StringType, - []attr.Value{ - types.StringValue("10"), - types.StringValue("20"), - types.StringValue("30"), - }, - ), - validator: stringvalidator.OneOf( - "10", - "20", - "30", - "40", - "50", - ), - expErrors: 1, - }, - "set-not-allowed": { - in: types.SetValueMust( - types.StringType, - []attr.Value{ - types.StringValue("foo"), - types.StringValue("bar"), - types.StringValue("baz"), - }, - ), - validator: stringvalidator.OneOf( - "bob", - "alice", - "john", - "foo", - "bar", - "baz", - ), - expErrors: 1, - }, - "map-not-allowed": { - in: types.MapValueMust( - types.StringType, - map[string]attr.Value{ - "one.one": types.StringValue("1.1"), - "ten.twenty": types.StringValue("10.20"), - "five.four": types.StringValue("5.4"), - }, - ), - validator: stringvalidator.OneOf( - "1.1", - "10.20", - "5.4", - "geronimo", - "bob", - ), - expErrors: 1, - }, - "object-not-allowed": { - in: types.ObjectValueMust( - objAttrTypes, - map[string]attr.Value{ - "Name": types.StringValue("Bob Parr"), - "Age": types.StringValue("40"), - "Address": types.StringValue("1200 Park Avenue Emeryville"), - }, - ), - validator: stringvalidator.OneOf( - "Bob Parr", - "40", - "1200 Park Avenue Emeryville", - "123", - ), - expErrors: 1, - }, "skip-validation-on-null": { in: types.StringNull(), validator: stringvalidator.OneOf( @@ -149,170 +70,11 @@ func TestOneOfValidator(t *testing.T) { for name, test := range testCases { name, test := name, test t.Run(name, func(t *testing.T) { - req := tfsdk.ValidateAttributeRequest{ - AttributeConfig: test.in, - } - res := tfsdk.ValidateAttributeResponse{} - test.validator.Validate(context.TODO(), req, &res) - - if test.expErrors > 0 && !res.Diagnostics.HasError() { - t.Fatalf("expected %d error(s), got none", test.expErrors) - } - - if test.expErrors > 0 && test.expErrors != res.Diagnostics.ErrorsCount() { - t.Fatalf("expected %d error(s), got %d: %v", test.expErrors, res.Diagnostics.ErrorsCount(), res.Diagnostics) - } - - if test.expErrors == 0 && res.Diagnostics.HasError() { - t.Fatalf("expected no error(s), got %d: %v", res.Diagnostics.ErrorsCount(), res.Diagnostics) - } - }) - } -} - -func TestOneOfCaseInsensitiveValidator(t *testing.T) { - t.Parallel() - - type testCase struct { - in attr.Value - validator tfsdk.AttributeValidator - expErrors int - } - - objAttrTypes := map[string]attr.Type{ - "Name": types.StringType, - "Age": types.StringType, - "Address": types.StringType, - } - - testCases := map[string]testCase{ - "simple-match": { - in: types.StringValue("foo"), - validator: stringvalidator.OneOfCaseInsensitive( - "foo", - "bar", - "baz", - ), - expErrors: 0, - }, - "simple-match-case-insensitive": { - in: types.StringValue("foo"), - validator: stringvalidator.OneOfCaseInsensitive( - "FOO", - "bar", - "baz", - ), - expErrors: 0, - }, - "simple-mismatch": { - in: types.StringValue("foz"), - validator: stringvalidator.OneOfCaseInsensitive( - "foo", - "bar", - "baz", - ), - expErrors: 1, - }, - "list-not-allowed": { - in: types.ListValueMust( - types.StringType, - []attr.Value{ - types.StringValue("10"), - types.StringValue("20"), - types.StringValue("30"), - }, - ), - validator: stringvalidator.OneOfCaseInsensitive( - "10", - "20", - "30", - "40", - "50", - ), - expErrors: 1, - }, - "set-not-allowed": { - in: types.SetValueMust( - types.StringType, - []attr.Value{ - types.StringValue("foo"), - types.StringValue("bar"), - types.StringValue("baz"), - }, - ), - validator: stringvalidator.OneOfCaseInsensitive( - "bob", - "alice", - "john", - "foo", - "bar", - "baz", - ), - expErrors: 1, - }, - "map-not-allowed": { - in: types.MapValueMust( - types.StringType, - map[string]attr.Value{ - "one.one": types.StringValue("1.1"), - "ten.twenty": types.StringValue("10.20"), - "five.four": types.StringValue("5.4"), - }, - ), - validator: stringvalidator.OneOfCaseInsensitive( - "1.1", - "10.20", - "5.4", - "geronimo", - "bob", - ), - expErrors: 1, - }, - "object-not-allowed": { - in: types.ObjectValueMust( - objAttrTypes, - map[string]attr.Value{ - "Name": types.StringValue("Bob Parr"), - "Age": types.StringValue("40"), - "Address": types.StringValue("1200 Park Avenue Emeryville"), - }, - ), - validator: stringvalidator.OneOfCaseInsensitive( - "Bob Parr", - "40", - "1200 Park Avenue Emeryville", - "123", - ), - expErrors: 1, - }, - "skip-validation-on-null": { - in: types.StringNull(), - validator: stringvalidator.OneOfCaseInsensitive( - "foo", - "bar", - "baz", - ), - expErrors: 0, - }, - "skip-validation-on-unknown": { - in: types.StringUnknown(), - validator: stringvalidator.OneOfCaseInsensitive( - "foo", - "bar", - "baz", - ), - expErrors: 0, - }, - } - - for name, test := range testCases { - name, test := name, test - t.Run(name, func(t *testing.T) { - req := tfsdk.ValidateAttributeRequest{ - AttributeConfig: test.in, + req := validator.StringRequest{ + ConfigValue: test.in, } - res := tfsdk.ValidateAttributeResponse{} - test.validator.Validate(context.TODO(), req, &res) + res := validator.StringResponse{} + test.validator.ValidateString(context.TODO(), req, &res) if test.expErrors > 0 && !res.Diagnostics.HasError() { t.Fatalf("expected %d error(s), got none", test.expErrors) diff --git a/stringvalidator/regex_matches.go b/stringvalidator/regex_matches.go index 7fd5ae3..5ab9803 100644 --- a/stringvalidator/regex_matches.go +++ b/stringvalidator/regex_matches.go @@ -6,10 +6,10 @@ import ( "regexp" "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" ) -var _ tfsdk.AttributeValidator = regexMatchesValidator{} +var _ validator.String = regexMatchesValidator{} // regexMatchesValidator validates that a string Attribute's value matches the specified regular expression. type regexMatchesValidator struct { @@ -31,18 +31,18 @@ func (validator regexMatchesValidator) MarkdownDescription(ctx context.Context) } // Validate performs the validation. -func (validator regexMatchesValidator) Validate(ctx context.Context, request tfsdk.ValidateAttributeRequest, response *tfsdk.ValidateAttributeResponse) { - s, ok := validateString(ctx, request, response) - - if !ok { +func (v regexMatchesValidator) ValidateString(ctx context.Context, request validator.StringRequest, response *validator.StringResponse) { + if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() { return } - if ok := validator.regexp.MatchString(s); !ok { + value := request.ConfigValue.ValueString() + + if !v.regexp.MatchString(value) { response.Diagnostics.Append(validatordiag.InvalidAttributeValueMatchDiagnostic( - request.AttributePath, - validator.Description(ctx), - s, + request.Path, + v.Description(ctx), + value, )) } } @@ -56,7 +56,7 @@ func (validator regexMatchesValidator) Validate(ctx context.Context, request tfs // Null (unconfigured) and unknown (known after apply) values are skipped. // Optionally an error message can be provided to return something friendlier // than "value must match regular expression 'regexp'". -func RegexMatches(regexp *regexp.Regexp, message string) tfsdk.AttributeValidator { +func RegexMatches(regexp *regexp.Regexp, message string) validator.String { return regexMatchesValidator{ regexp: regexp, message: message, diff --git a/stringvalidator/regex_matches_example_test.go b/stringvalidator/regex_matches_example_test.go index 7194709..de1b3dd 100644 --- a/stringvalidator/regex_matches_example_test.go +++ b/stringvalidator/regex_matches_example_test.go @@ -4,18 +4,17 @@ import ( "regexp" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" ) func ExampleRegexMatches() { - // Used within a GetSchema method of a DataSource, Provider, or Resource - _ = tfsdk.Schema{ - Attributes: map[string]tfsdk.Attribute{ - "example_attr": { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.StringAttribute{ Required: true, - Type: types.StringType, - Validators: []tfsdk.AttributeValidator{ + Validators: []validator.String{ // Validate string value satisfies the regular expression for alphanumeric characters stringvalidator.RegexMatches( regexp.MustCompile(`^[a-zA-Z0-9]*$`), diff --git a/stringvalidator/regex_matches_test.go b/stringvalidator/regex_matches_test.go index 22e384d..ae6ce38 100644 --- a/stringvalidator/regex_matches_test.go +++ b/stringvalidator/regex_matches_test.go @@ -5,9 +5,8 @@ import ( "regexp" "testing" - "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" @@ -17,15 +16,11 @@ func TestRegexMatchesValidator(t *testing.T) { t.Parallel() type testCase struct { - val attr.Value + val types.String regexp *regexp.Regexp expectError bool } tests := map[string]testCase{ - "not a String": { - val: types.BoolValue(true), - expectError: true, - }, "unknown String": { val: types.StringUnknown(), regexp: regexp.MustCompile(`^o[j-l]?$`), @@ -48,13 +43,13 @@ func TestRegexMatchesValidator(t *testing.T) { for name, test := range tests { name, test := name, test t.Run(name, func(t *testing.T) { - request := tfsdk.ValidateAttributeRequest{ - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - AttributeConfig: test.val, + request := validator.StringRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, } - response := tfsdk.ValidateAttributeResponse{} - stringvalidator.RegexMatches(test.regexp, "").Validate(context.TODO(), request, &response) + response := validator.StringResponse{} + stringvalidator.RegexMatches(test.regexp, "").ValidateString(context.TODO(), request, &response) if !response.Diagnostics.HasError() && test.expectError { t.Fatal("expected error, got no error") diff --git a/stringvalidator/type_validation.go b/stringvalidator/type_validation.go deleted file mode 100644 index c6b5931..0000000 --- a/stringvalidator/type_validation.go +++ /dev/null @@ -1,30 +0,0 @@ -package stringvalidator - -import ( - "context" - - "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" -) - -// validateString ensures that the request contains a String value. -func validateString(ctx context.Context, request tfsdk.ValidateAttributeRequest, response *tfsdk.ValidateAttributeResponse) (string, bool) { - t := request.AttributeConfig.Type(ctx) - if t != types.StringType { - response.Diagnostics.Append(validatordiag.InvalidAttributeTypeDiagnostic( - request.AttributePath, - "expected value of type string", - t.String(), - )) - return "", false - } - - s := request.AttributeConfig.(types.String) - - if s.IsUnknown() || s.IsNull() { - return "", false - } - - return s.ValueString(), true -} diff --git a/stringvalidator/type_validation_test.go b/stringvalidator/type_validation_test.go deleted file mode 100644 index 2bf9541..0000000 --- a/stringvalidator/type_validation_test.go +++ /dev/null @@ -1,76 +0,0 @@ -package stringvalidator - -import ( - "context" - "testing" - - "github.com/google/go-cmp/cmp" - "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" -) - -func TestValidateString(t *testing.T) { - t.Parallel() - - testCases := map[string]struct { - request tfsdk.ValidateAttributeRequest - expectedString string - expectedOk bool - }{ - "invalid-type": { - request: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.BoolValue(true), - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - }, - expectedString: "", - expectedOk: false, - }, - "string-null": { - request: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.Int64Null(), - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - }, - expectedString: "", - expectedOk: false, - }, - "string-value": { - request: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.StringValue("test-value"), - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - }, - expectedString: "test-value", - expectedOk: true, - }, - "string-unknown": { - request: tfsdk.ValidateAttributeRequest{ - AttributeConfig: types.Int64Unknown(), - AttributePath: path.Root("test"), - AttributePathExpression: path.MatchRoot("test"), - }, - expectedString: "", - expectedOk: false, - }, - } - - for name, testCase := range testCases { - name, testCase := name, testCase - - t.Run(name, func(t *testing.T) { - t.Parallel() - - gotInt64, gotOk := validateString(context.Background(), testCase.request, &tfsdk.ValidateAttributeResponse{}) - - if diff := cmp.Diff(gotInt64, testCase.expectedString); diff != "" { - t.Errorf("unexpected float64 difference: %s", diff) - } - - if diff := cmp.Diff(gotOk, testCase.expectedOk); diff != "" { - t.Errorf("unexpected ok difference: %s", diff) - } - }) - } -}