From ba395d21389b1aca71718c0ed38fbb1c0e40212e Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Tue, 9 Jul 2024 12:35:41 -0400 Subject: [PATCH] all: Add support for Int32 and Float32 Type Validators (#222) * Add `float32validator` package * Add `int32validator` package * Add `Int32Validator` and `Float32Validator` support to `AlsoRequiresValidator`, `AtLeastOneOfValidator`, `ConflictsWithValidator`, and `ExactlyOneOfValidator` * Add `ValueFloat32sAre` and `ValueInt32sAre` validators to `listvalidator`, `mapvalidator`, and `setvalidator` packages * Update `terraform-plugin-framework` dependency to int32/float32 implementation branch * Add changelog entries * Update comments to use integer constants instead of float Co-authored-by: Austin Valle * Update `int64validator` example tests to use integers instead of floats --------- Co-authored-by: Austin Valle --- .../ENHANCEMENTS-20240626-174347.yaml | 5 + .../ENHANCEMENTS-20240626-174411.yaml | 5 + .../ENHANCEMENTS-20240626-174423.yaml | 5 + .../unreleased/FEATURES-20240626-174145.yaml | 5 + .../unreleased/FEATURES-20240626-174220.yaml | 5 + float32validator/all.go | 57 ++++++ float32validator/all_example_test.go | 34 ++++ float32validator/all_test.go | 74 +++++++ float32validator/also_requires.go | 27 +++ .../also_requires_example_test.go | 32 +++ float32validator/any.go | 65 +++++++ float32validator/any_example_test.go | 31 +++ float32validator/any_test.go | 85 ++++++++ float32validator/any_with_all_warnings.go | 67 +++++++ .../any_with_all_warnings_example_test.go | 31 +++ .../any_with_all_warnings_test.go | 86 ++++++++ float32validator/at_least.go | 60 ++++++ float32validator/at_least_example_test.go | 26 +++ float32validator/at_least_one_of.go | 28 +++ .../at_least_one_of_example_test.go | 32 +++ float32validator/at_least_test.go | 74 +++++++ float32validator/at_most.go | 60 ++++++ float32validator/at_most_example_test.go | 26 +++ float32validator/at_most_test.go | 74 +++++++ float32validator/between.go | 65 +++++++ float32validator/between_example_test.go | 26 +++ float32validator/between_test.go | 92 +++++++++ float32validator/conflicts_with.go | 28 +++ .../conflicts_with_example_test.go | 32 +++ float32validator/doc.go | 5 + float32validator/exactly_one_of.go | 29 +++ .../exactly_one_of_example_test.go | 32 +++ float32validator/none_of.go | 65 +++++++ float32validator/none_of_example_test.go | 26 +++ float32validator/none_of_test.go | 88 +++++++++ float32validator/one_of.go | 63 ++++++ float32validator/one_of_example_test.go | 26 +++ float32validator/one_of_test.go | 88 +++++++++ int32validator/all.go | 57 ++++++ int32validator/all_example_test.go | 34 ++++ int32validator/all_test.go | 74 +++++++ int32validator/also_requires.go | 27 +++ int32validator/also_requires_example_test.go | 32 +++ int32validator/any.go | 65 +++++++ int32validator/any_example_test.go | 31 +++ int32validator/any_test.go | 85 ++++++++ int32validator/any_with_all_warnings.go | 67 +++++++ .../any_with_all_warnings_example_test.go | 31 +++ int32validator/any_with_all_warnings_test.go | 86 ++++++++ int32validator/at_least.go | 58 ++++++ int32validator/at_least_example_test.go | 26 +++ int32validator/at_least_one_of.go | 28 +++ .../at_least_one_of_example_test.go | 32 +++ int32validator/at_least_sum_of.go | 116 +++++++++++ .../at_least_sum_of_example_test.go | 37 ++++ int32validator/at_least_sum_of_test.go | 182 +++++++++++++++++ int32validator/at_least_test.go | 70 +++++++ int32validator/at_most.go | 58 ++++++ int32validator/at_most_example_test.go | 26 +++ int32validator/at_most_sum_of.go | 116 +++++++++++ int32validator/at_most_sum_of_example_test.go | 37 ++++ int32validator/at_most_sum_of_test.go | 182 +++++++++++++++++ int32validator/at_most_test.go | 70 +++++++ int32validator/between.go | 63 ++++++ int32validator/between_example_test.go | 26 +++ int32validator/between_test.go | 87 +++++++++ int32validator/conflicts_with.go | 28 +++ int32validator/conflicts_with_example_test.go | 32 +++ int32validator/doc.go | 5 + int32validator/equal_to_product_of.go | 116 +++++++++++ .../equal_to_product_of_example_test.go | 37 ++++ int32validator/equal_to_product_of_test.go | 182 +++++++++++++++++ int32validator/equal_to_sum_of.go | 116 +++++++++++ .../equal_to_sum_of_example_test.go | 37 ++++ int32validator/equal_to_sum_of_test.go | 183 ++++++++++++++++++ int32validator/exactly_one_of.go | 29 +++ int32validator/exactly_one_of_example_test.go | 32 +++ int32validator/none_of.go | 65 +++++++ int32validator/none_of_example_test.go | 26 +++ int32validator/none_of_test.go | 88 +++++++++ int32validator/one_of.go | 63 ++++++ int32validator/one_of_example_test.go | 26 +++ int32validator/one_of_test.go | 88 +++++++++ int64validator/all_example_test.go | 13 +- int64validator/any_example_test.go | 11 +- .../any_with_all_warnings_example_test.go | 11 +- internal/schemavalidator/also_requires.go | 33 +++- internal/schemavalidator/at_least_one_of.go | 33 +++- internal/schemavalidator/conflicts_with.go | 33 +++- internal/schemavalidator/exactly_one_of.go | 33 +++- internal/testvalidator/warning.go | 26 +++ listvalidator/value_float32s_are.go | 119 ++++++++++++ .../value_float32s_are_example_test.go | 29 +++ listvalidator/value_float32s_are_test.go | 146 ++++++++++++++ listvalidator/value_int32s_are.go | 119 ++++++++++++ .../value_int32s_are_example_test.go | 29 +++ listvalidator/value_int32s_are_test.go | 141 ++++++++++++++ mapvalidator/value_float32s_are.go | 119 ++++++++++++ .../value_float32s_are_example_test.go | 29 +++ mapvalidator/value_float32s_are_test.go | 131 +++++++++++++ mapvalidator/value_int32s_are.go | 119 ++++++++++++ mapvalidator/value_int32s_are_example_test.go | 29 +++ mapvalidator/value_int32s_are_test.go | 131 +++++++++++++ setvalidator/value_float32s_are.go | 119 ++++++++++++ .../value_float32s_are_example_test.go | 29 +++ setvalidator/value_float32s_are_test.go | 146 ++++++++++++++ setvalidator/value_int32s_are.go | 119 ++++++++++++ setvalidator/value_int32s_are_example_test.go | 29 +++ setvalidator/value_int32s_are_test.go | 146 ++++++++++++++ 109 files changed, 6517 insertions(+), 20 deletions(-) create mode 100644 .changes/unreleased/ENHANCEMENTS-20240626-174347.yaml create mode 100644 .changes/unreleased/ENHANCEMENTS-20240626-174411.yaml create mode 100644 .changes/unreleased/ENHANCEMENTS-20240626-174423.yaml create mode 100644 .changes/unreleased/FEATURES-20240626-174145.yaml create mode 100644 .changes/unreleased/FEATURES-20240626-174220.yaml create mode 100644 float32validator/all.go create mode 100644 float32validator/all_example_test.go create mode 100644 float32validator/all_test.go create mode 100644 float32validator/also_requires.go create mode 100644 float32validator/also_requires_example_test.go create mode 100644 float32validator/any.go create mode 100644 float32validator/any_example_test.go create mode 100644 float32validator/any_test.go create mode 100644 float32validator/any_with_all_warnings.go create mode 100644 float32validator/any_with_all_warnings_example_test.go create mode 100644 float32validator/any_with_all_warnings_test.go create mode 100644 float32validator/at_least.go create mode 100644 float32validator/at_least_example_test.go create mode 100644 float32validator/at_least_one_of.go create mode 100644 float32validator/at_least_one_of_example_test.go create mode 100644 float32validator/at_least_test.go create mode 100644 float32validator/at_most.go create mode 100644 float32validator/at_most_example_test.go create mode 100644 float32validator/at_most_test.go create mode 100644 float32validator/between.go create mode 100644 float32validator/between_example_test.go create mode 100644 float32validator/between_test.go create mode 100644 float32validator/conflicts_with.go create mode 100644 float32validator/conflicts_with_example_test.go create mode 100644 float32validator/doc.go create mode 100644 float32validator/exactly_one_of.go create mode 100644 float32validator/exactly_one_of_example_test.go create mode 100644 float32validator/none_of.go create mode 100644 float32validator/none_of_example_test.go create mode 100644 float32validator/none_of_test.go create mode 100644 float32validator/one_of.go create mode 100644 float32validator/one_of_example_test.go create mode 100644 float32validator/one_of_test.go create mode 100644 int32validator/all.go create mode 100644 int32validator/all_example_test.go create mode 100644 int32validator/all_test.go create mode 100644 int32validator/also_requires.go create mode 100644 int32validator/also_requires_example_test.go create mode 100644 int32validator/any.go create mode 100644 int32validator/any_example_test.go create mode 100644 int32validator/any_test.go create mode 100644 int32validator/any_with_all_warnings.go create mode 100644 int32validator/any_with_all_warnings_example_test.go create mode 100644 int32validator/any_with_all_warnings_test.go create mode 100644 int32validator/at_least.go create mode 100644 int32validator/at_least_example_test.go create mode 100644 int32validator/at_least_one_of.go create mode 100644 int32validator/at_least_one_of_example_test.go create mode 100644 int32validator/at_least_sum_of.go create mode 100644 int32validator/at_least_sum_of_example_test.go create mode 100644 int32validator/at_least_sum_of_test.go create mode 100644 int32validator/at_least_test.go create mode 100644 int32validator/at_most.go create mode 100644 int32validator/at_most_example_test.go create mode 100644 int32validator/at_most_sum_of.go create mode 100644 int32validator/at_most_sum_of_example_test.go create mode 100644 int32validator/at_most_sum_of_test.go create mode 100644 int32validator/at_most_test.go create mode 100644 int32validator/between.go create mode 100644 int32validator/between_example_test.go create mode 100644 int32validator/between_test.go create mode 100644 int32validator/conflicts_with.go create mode 100644 int32validator/conflicts_with_example_test.go create mode 100644 int32validator/doc.go create mode 100644 int32validator/equal_to_product_of.go create mode 100644 int32validator/equal_to_product_of_example_test.go create mode 100644 int32validator/equal_to_product_of_test.go create mode 100644 int32validator/equal_to_sum_of.go create mode 100644 int32validator/equal_to_sum_of_example_test.go create mode 100644 int32validator/equal_to_sum_of_test.go create mode 100644 int32validator/exactly_one_of.go create mode 100644 int32validator/exactly_one_of_example_test.go create mode 100644 int32validator/none_of.go create mode 100644 int32validator/none_of_example_test.go create mode 100644 int32validator/none_of_test.go create mode 100644 int32validator/one_of.go create mode 100644 int32validator/one_of_example_test.go create mode 100644 int32validator/one_of_test.go create mode 100644 listvalidator/value_float32s_are.go create mode 100644 listvalidator/value_float32s_are_example_test.go create mode 100644 listvalidator/value_float32s_are_test.go create mode 100644 listvalidator/value_int32s_are.go create mode 100644 listvalidator/value_int32s_are_example_test.go create mode 100644 listvalidator/value_int32s_are_test.go create mode 100644 mapvalidator/value_float32s_are.go create mode 100644 mapvalidator/value_float32s_are_example_test.go create mode 100644 mapvalidator/value_float32s_are_test.go create mode 100644 mapvalidator/value_int32s_are.go create mode 100644 mapvalidator/value_int32s_are_example_test.go create mode 100644 mapvalidator/value_int32s_are_test.go create mode 100644 setvalidator/value_float32s_are.go create mode 100644 setvalidator/value_float32s_are_example_test.go create mode 100644 setvalidator/value_float32s_are_test.go create mode 100644 setvalidator/value_int32s_are.go create mode 100644 setvalidator/value_int32s_are_example_test.go create mode 100644 setvalidator/value_int32s_are_test.go diff --git a/.changes/unreleased/ENHANCEMENTS-20240626-174347.yaml b/.changes/unreleased/ENHANCEMENTS-20240626-174347.yaml new file mode 100644 index 0000000..a76ba41 --- /dev/null +++ b/.changes/unreleased/ENHANCEMENTS-20240626-174347.yaml @@ -0,0 +1,5 @@ +kind: ENHANCEMENTS +body: 'listvalidator: Added `ValueInt32sAre` and `ValueFloat32sAre` validators' +time: 2024-06-26T17:43:47.501066-04:00 +custom: + Issue: "222" diff --git a/.changes/unreleased/ENHANCEMENTS-20240626-174411.yaml b/.changes/unreleased/ENHANCEMENTS-20240626-174411.yaml new file mode 100644 index 0000000..fe0e696 --- /dev/null +++ b/.changes/unreleased/ENHANCEMENTS-20240626-174411.yaml @@ -0,0 +1,5 @@ +kind: ENHANCEMENTS +body: 'mapvalidator: Added `ValueInt32sAre` and `ValueFloat32sAre` validators' +time: 2024-06-26T17:44:11.352117-04:00 +custom: + Issue: "222" diff --git a/.changes/unreleased/ENHANCEMENTS-20240626-174423.yaml b/.changes/unreleased/ENHANCEMENTS-20240626-174423.yaml new file mode 100644 index 0000000..5ea6671 --- /dev/null +++ b/.changes/unreleased/ENHANCEMENTS-20240626-174423.yaml @@ -0,0 +1,5 @@ +kind: ENHANCEMENTS +body: 'setvalidator: Added `ValueInt32sAre` and `ValueFloat32sAre` validators' +time: 2024-06-26T17:44:23.323248-04:00 +custom: + Issue: "222" diff --git a/.changes/unreleased/FEATURES-20240626-174145.yaml b/.changes/unreleased/FEATURES-20240626-174145.yaml new file mode 100644 index 0000000..aecdcc5 --- /dev/null +++ b/.changes/unreleased/FEATURES-20240626-174145.yaml @@ -0,0 +1,5 @@ +kind: FEATURES +body: 'int32validator: New package which contains int32 type specific validators' +time: 2024-06-26T17:41:45.007609-04:00 +custom: + Issue: "222" diff --git a/.changes/unreleased/FEATURES-20240626-174220.yaml b/.changes/unreleased/FEATURES-20240626-174220.yaml new file mode 100644 index 0000000..472bfef --- /dev/null +++ b/.changes/unreleased/FEATURES-20240626-174220.yaml @@ -0,0 +1,5 @@ +kind: FEATURES +body: 'float32validator: New package which contains float32 type specific validators' +time: 2024-06-26T17:42:20.2122-04:00 +custom: + Issue: "222" diff --git a/float32validator/all.go b/float32validator/all.go new file mode 100644 index 0000000..c690dc7 --- /dev/null +++ b/float32validator/all.go @@ -0,0 +1,57 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float32validator + +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.Float32) validator.Float32 { + return allValidator{ + validators: validators, + } +} + +var _ validator.Float32 = allValidator{} + +// allValidator implements the validator. +type allValidator struct { + validators []validator.Float32 +} + +// 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) +} + +// ValidateFloat32 performs the validation. +func (v allValidator) ValidateFloat32(ctx context.Context, req validator.Float32Request, resp *validator.Float32Response) { + for _, subValidator := range v.validators { + validateResp := &validator.Float32Response{} + + subValidator.ValidateFloat32(ctx, req, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) + } +} diff --git a/float32validator/all_example_test.go b/float32validator/all_example_test.go new file mode 100644 index 0000000..178640a --- /dev/null +++ b/float32validator/all_example_test.go @@ -0,0 +1,34 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float32validator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + + "github.com/hashicorp/terraform-plugin-framework-validators/float32validator" +) + +func ExampleAll() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Float32Attribute{ + Required: true, + Validators: []validator.Float32{ + // Validate this Float32 value must either be: + // - 1.0 + // - At least 2.0, but not 3.0 + float32validator.Any( + float32validator.OneOf(1.0), + float32validator.All( + float32validator.AtLeast(2.0), + float32validator.NoneOf(3.0), + ), + ), + }, + }, + }, + } +} diff --git a/float32validator/all_test.go b/float32validator/all_test.go new file mode 100644 index 0000000..f5f22cb --- /dev/null +++ b/float32validator/all_test.go @@ -0,0 +1,74 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float32validator_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/float32validator" +) + +func TestAllValidatorValidateFloat32(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.Float32 + validators []validator.Float32 + expected diag.Diagnostics + } + tests := map[string]testCase{ + "invalid": { + val: types.Float32Value(1.2), + validators: []validator.Float32{ + float32validator.AtLeast(3), + float32validator.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.Float32Value(1.2), + validators: []validator.Float32{ + float32validator.AtLeast(0), + float32validator.AtLeast(1), + }, + expected: nil, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + request := validator.Float32Request{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + } + response := validator.Float32Response{} + float32validator.All(test.validators...).ValidateFloat32(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, test.expected); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/float32validator/also_requires.go b/float32validator/also_requires.go new file mode 100644 index 0000000..71ea8d7 --- /dev/null +++ b/float32validator/also_requires.go @@ -0,0 +1,27 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float32validator + +import ( + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" +) + +// 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.Float32 { + return schemavalidator.AlsoRequiresValidator{ + PathExpressions: expressions, + } +} diff --git a/float32validator/also_requires_example_test.go b/float32validator/also_requires_example_test.go new file mode 100644 index 0000000..02c1449 --- /dev/null +++ b/float32validator/also_requires_example_test.go @@ -0,0 +1,32 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float32validator_test + +import ( + "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-validators/float32validator" +) + +func ExampleAlsoRequires() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Float32Attribute{ + Optional: true, + Validators: []validator.Float32{ + // Validate this attribute must be configured with other_attr. + float32validator.AlsoRequires(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.StringAttribute{ + Optional: true, + }, + }, + } +} diff --git a/float32validator/any.go b/float32validator/any.go new file mode 100644 index 0000000..6d056bb --- /dev/null +++ b/float32validator/any.go @@ -0,0 +1,65 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float32validator + +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.Float32) validator.Float32 { + return anyValidator{ + validators: validators, + } +} + +var _ validator.Float32 = anyValidator{} + +// anyValidator implements the validator. +type anyValidator struct { + validators []validator.Float32 +} + +// 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) +} + +// ValidateFloat32 performs the validation. +func (v anyValidator) ValidateFloat32(ctx context.Context, req validator.Float32Request, resp *validator.Float32Response) { + for _, subValidator := range v.validators { + validateResp := &validator.Float32Response{} + + subValidator.ValidateFloat32(ctx, req, validateResp) + + if !validateResp.Diagnostics.HasError() { + resp.Diagnostics = validateResp.Diagnostics + + return + } + + resp.Diagnostics.Append(validateResp.Diagnostics...) + } +} diff --git a/float32validator/any_example_test.go b/float32validator/any_example_test.go new file mode 100644 index 0000000..3bcfbb4 --- /dev/null +++ b/float32validator/any_example_test.go @@ -0,0 +1,31 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float32validator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + + "github.com/hashicorp/terraform-plugin-framework-validators/float32validator" +) + +func ExampleAny() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Float32Attribute{ + Required: true, + Validators: []validator.Float32{ + // Validate this Float32 value must either be: + // - 1.0 + // - At least 2.0 + float32validator.Any( + float32validator.OneOf(1.0), + float32validator.AtLeast(2.0), + ), + }, + }, + }, + } +} diff --git a/float32validator/any_test.go b/float32validator/any_test.go new file mode 100644 index 0000000..1b5b9a2 --- /dev/null +++ b/float32validator/any_test.go @@ -0,0 +1,85 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float32validator_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/float32validator" + "github.com/hashicorp/terraform-plugin-framework-validators/internal/testvalidator" +) + +func TestAnyValidatorValidateFloat32(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.Float32 + validators []validator.Float32 + expected diag.Diagnostics + } + tests := map[string]testCase{ + "invalid": { + val: types.Float32Value(1.2), + validators: []validator.Float32{ + float32validator.AtLeast(3), + float32validator.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.Float32Value(4), + validators: []validator.Float32{ + float32validator.AtLeast(5), + float32validator.AtLeast(3), + }, + expected: diag.Diagnostics{}, + }, + "valid with warning": { + val: types.Float32Value(4), + validators: []validator.Float32{ + float32validator.All(float32validator.AtLeast(5), testvalidator.WarningFloat32("failing warning summary", "failing warning details")), + float32validator.All(float32validator.AtLeast(2), testvalidator.WarningFloat32("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) { + t.Parallel() + request := validator.Float32Request{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + } + response := validator.Float32Response{} + float32validator.Any(test.validators...).ValidateFloat32(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, test.expected); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/float32validator/any_with_all_warnings.go b/float32validator/any_with_all_warnings.go new file mode 100644 index 0000000..f1f14f0 --- /dev/null +++ b/float32validator/any_with_all_warnings.go @@ -0,0 +1,67 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float32validator + +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.Float32) validator.Float32 { + return anyWithAllWarningsValidator{ + validators: validators, + } +} + +var _ validator.Float32 = anyWithAllWarningsValidator{} + +// anyWithAllWarningsValidator implements the validator. +type anyWithAllWarningsValidator struct { + validators []validator.Float32 +} + +// 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) +} + +// ValidateFloat32 performs the validation. +func (v anyWithAllWarningsValidator) ValidateFloat32(ctx context.Context, req validator.Float32Request, resp *validator.Float32Response) { + anyValid := false + + for _, subValidator := range v.validators { + validateResp := &validator.Float32Response{} + + subValidator.ValidateFloat32(ctx, req, validateResp) + + if !validateResp.Diagnostics.HasError() { + anyValid = true + } + + resp.Diagnostics.Append(validateResp.Diagnostics...) + } + + if anyValid { + resp.Diagnostics = resp.Diagnostics.Warnings() + } +} diff --git a/float32validator/any_with_all_warnings_example_test.go b/float32validator/any_with_all_warnings_example_test.go new file mode 100644 index 0000000..5b6dac3 --- /dev/null +++ b/float32validator/any_with_all_warnings_example_test.go @@ -0,0 +1,31 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float32validator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + + "github.com/hashicorp/terraform-plugin-framework-validators/float32validator" +) + +func ExampleAnyWithAllWarnings() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Float32Attribute{ + Required: true, + Validators: []validator.Float32{ + // Validate this Float32 value must either be: + // - 1.0 + // - At least 2.0 + float32validator.AnyWithAllWarnings( + float32validator.OneOf(1.0), + float32validator.AtLeast(2.0), + ), + }, + }, + }, + } +} diff --git a/float32validator/any_with_all_warnings_test.go b/float32validator/any_with_all_warnings_test.go new file mode 100644 index 0000000..4bacc5c --- /dev/null +++ b/float32validator/any_with_all_warnings_test.go @@ -0,0 +1,86 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float32validator_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/float32validator" + "github.com/hashicorp/terraform-plugin-framework-validators/internal/testvalidator" +) + +func TestAnyWithAllWarningsValidatorValidateFloat32(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.Float32 + validators []validator.Float32 + expected diag.Diagnostics + } + tests := map[string]testCase{ + "invalid": { + val: types.Float32Value(1.2), + validators: []validator.Float32{ + float32validator.AtLeast(3), + float32validator.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.Float32Value(4), + validators: []validator.Float32{ + float32validator.AtLeast(5), + float32validator.AtLeast(3), + }, + expected: diag.Diagnostics{}, + }, + "valid with warning": { + val: types.Float32Value(4), + validators: []validator.Float32{ + float32validator.All(float32validator.AtLeast(5), testvalidator.WarningFloat32("failing warning summary", "failing warning details")), + float32validator.All(float32validator.AtLeast(2), testvalidator.WarningFloat32("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) { + t.Parallel() + request := validator.Float32Request{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + } + response := validator.Float32Response{} + float32validator.AnyWithAllWarnings(test.validators...).ValidateFloat32(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, test.expected); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/float32validator/at_least.go b/float32validator/at_least.go new file mode 100644 index 0000000..db5192b --- /dev/null +++ b/float32validator/at_least.go @@ -0,0 +1,60 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float32validator + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + + "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" +) + +var _ validator.Float32 = atLeastValidator{} + +// atLeastValidator validates that an float Attribute's value is at least a certain value. +type atLeastValidator struct { + min float32 +} + +// Description describes the validation in plain text formatting. +func (validator atLeastValidator) Description(_ context.Context) string { + return fmt.Sprintf("value must be at least %f", validator.min) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (validator atLeastValidator) MarkdownDescription(ctx context.Context) string { + return validator.Description(ctx) +} + +// ValidateFloat32 performs the validation. +func (validator atLeastValidator) ValidateFloat32(ctx context.Context, request validator.Float32Request, response *validator.Float32Response) { + if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() { + return + } + + value := request.ConfigValue.ValueFloat32() + + if value < validator.min { + response.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( + request.Path, + validator.Description(ctx), + fmt.Sprintf("%f", value), + )) + } +} + +// AtLeast returns an AttributeValidator which ensures that any configured +// attribute value: +// +// - Is a number, which can be represented by a 32-bit floating point. +// - Is greater than or equal to the given minimum. +// +// Null (unconfigured) and unknown (known after apply) values are skipped. +func AtLeast(min float32) validator.Float32 { + return atLeastValidator{ + min: min, + } +} diff --git a/float32validator/at_least_example_test.go b/float32validator/at_least_example_test.go new file mode 100644 index 0000000..bae9da7 --- /dev/null +++ b/float32validator/at_least_example_test.go @@ -0,0 +1,26 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float32validator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + + "github.com/hashicorp/terraform-plugin-framework-validators/float32validator" +) + +func ExampleAtLeast() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Float32Attribute{ + Required: true, + Validators: []validator.Float32{ + // Validate floating point value must be at least 42.42 + float32validator.AtLeast(42.42), + }, + }, + }, + } +} diff --git a/float32validator/at_least_one_of.go b/float32validator/at_least_one_of.go new file mode 100644 index 0000000..008087a --- /dev/null +++ b/float32validator/at_least_one_of.go @@ -0,0 +1,28 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float32validator + +import ( + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" +) + +// 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.Float32 { + return schemavalidator.AtLeastOneOfValidator{ + PathExpressions: expressions, + } +} diff --git a/float32validator/at_least_one_of_example_test.go b/float32validator/at_least_one_of_example_test.go new file mode 100644 index 0000000..2440d35 --- /dev/null +++ b/float32validator/at_least_one_of_example_test.go @@ -0,0 +1,32 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float32validator_test + +import ( + "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-validators/float32validator" +) + +func ExampleAtLeastOneOf() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Float32Attribute{ + Optional: true, + Validators: []validator.Float32{ + // Validate at least this attribute or other_attr should be configured. + float32validator.AtLeastOneOf(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.StringAttribute{ + Optional: true, + }, + }, + } +} diff --git a/float32validator/at_least_test.go b/float32validator/at_least_test.go new file mode 100644 index 0000000..6669900 --- /dev/null +++ b/float32validator/at_least_test.go @@ -0,0 +1,74 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float32validator_test + +import ( + "context" + "testing" + + "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/float32validator" +) + +func TestAtLeastValidator(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.Float32 + min float32 + expectError bool + } + tests := map[string]testCase{ + "unknown Float32": { + val: types.Float32Unknown(), + min: 0.90, + }, + "null Float32": { + val: types.Float32Null(), + min: 0.90, + }, + "valid integer as Float32": { + val: types.Float32Value(2), + min: 0.90, + }, + "valid float as Float32": { + val: types.Float32Value(2.2), + min: 0.90, + }, + "valid float as Float32 min": { + val: types.Float32Value(0.9), + min: 0.90, + }, + "too small float as Float32": { + val: types.Float32Value(-1.1111), + min: 0.90, + expectError: true, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + request := validator.Float32Request{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + } + response := validator.Float32Response{} + float32validator.AtLeast(test.min).ValidateFloat32(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/float32validator/at_most.go b/float32validator/at_most.go new file mode 100644 index 0000000..0d5e778 --- /dev/null +++ b/float32validator/at_most.go @@ -0,0 +1,60 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float32validator + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + + "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" +) + +var _ validator.Float32 = atMostValidator{} + +// atMostValidator validates that an float Attribute's value is at most a certain value. +type atMostValidator struct { + max float32 +} + +// Description describes the validation in plain text formatting. +func (validator atMostValidator) Description(_ context.Context) string { + return fmt.Sprintf("value must be at most %f", validator.max) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (validator atMostValidator) MarkdownDescription(ctx context.Context) string { + return validator.Description(ctx) +} + +// ValidateFloat32 performs the validation. +func (v atMostValidator) ValidateFloat32(ctx context.Context, request validator.Float32Request, response *validator.Float32Response) { + if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() { + return + } + + value := request.ConfigValue.ValueFloat32() + + if value > v.max { + response.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( + request.Path, + v.Description(ctx), + fmt.Sprintf("%f", value), + )) + } +} + +// AtMost returns an AttributeValidator which ensures that any configured +// attribute value: +// +// - Is a number, which can be represented by a 32-bit floating point. +// - Is less than or equal to the given maximum. +// +// Null (unconfigured) and unknown (known after apply) values are skipped. +func AtMost(max float32) validator.Float32 { + return atMostValidator{ + max: max, + } +} diff --git a/float32validator/at_most_example_test.go b/float32validator/at_most_example_test.go new file mode 100644 index 0000000..6d3bef3 --- /dev/null +++ b/float32validator/at_most_example_test.go @@ -0,0 +1,26 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float32validator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + + "github.com/hashicorp/terraform-plugin-framework-validators/float32validator" +) + +func ExampleAtMost() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Float32Attribute{ + Required: true, + Validators: []validator.Float32{ + // Validate floating point value must be at most 42.42 + float32validator.AtMost(42.42), + }, + }, + }, + } +} diff --git a/float32validator/at_most_test.go b/float32validator/at_most_test.go new file mode 100644 index 0000000..cf5d1e7 --- /dev/null +++ b/float32validator/at_most_test.go @@ -0,0 +1,74 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float32validator_test + +import ( + "context" + "testing" + + "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/float32validator" +) + +func TestAtMostValidator(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.Float32 + max float32 + expectError bool + } + tests := map[string]testCase{ + "unknown Float32": { + val: types.Float32Unknown(), + max: 2.00, + }, + "null Float32": { + val: types.Float32Null(), + max: 2.00, + }, + "valid integer as Float32": { + val: types.Float32Value(1), + max: 2.00, + }, + "valid float as Float32": { + val: types.Float32Value(1.1), + max: 2.00, + }, + "valid float as Float32 max": { + val: types.Float32Value(2.0), + max: 2.00, + }, + "too large float as Float32": { + val: types.Float32Value(3.0), + max: 2.00, + expectError: true, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + request := validator.Float32Request{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + } + response := validator.Float32Response{} + float32validator.AtMost(test.max).ValidateFloat32(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/float32validator/between.go b/float32validator/between.go new file mode 100644 index 0000000..cbc8a7d --- /dev/null +++ b/float32validator/between.go @@ -0,0 +1,65 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float32validator + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + + "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" +) + +var _ validator.Float32 = betweenValidator{} + +// betweenValidator validates that an float Attribute's value is in a range. +type betweenValidator struct { + min, max float32 +} + +// Description describes the validation in plain text formatting. +func (validator betweenValidator) Description(_ context.Context) string { + return fmt.Sprintf("value must be between %f and %f", validator.min, validator.max) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (validator betweenValidator) MarkdownDescription(ctx context.Context) string { + return validator.Description(ctx) +} + +// ValidateFloat32 performs the validation. +func (v betweenValidator) ValidateFloat32(ctx context.Context, request validator.Float32Request, response *validator.Float32Response) { + if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() { + return + } + + value := request.ConfigValue.ValueFloat32() + + if value < v.min || value > v.max { + response.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( + request.Path, + v.Description(ctx), + fmt.Sprintf("%f", value), + )) + } +} + +// Between returns an AttributeValidator which ensures that any configured +// attribute value: +// +// - Is a number, which can be represented by a 32-bit floating point. +// - 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 float32) validator.Float32 { + if min > max { + return nil + } + + return betweenValidator{ + min: min, + max: max, + } +} diff --git a/float32validator/between_example_test.go b/float32validator/between_example_test.go new file mode 100644 index 0000000..25a4927 --- /dev/null +++ b/float32validator/between_example_test.go @@ -0,0 +1,26 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float32validator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + + "github.com/hashicorp/terraform-plugin-framework-validators/float32validator" +) + +func ExampleBetween() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Float32Attribute{ + Required: true, + Validators: []validator.Float32{ + // Validate floating point value must be at least 0.0 and at most 1.0 + float32validator.Between(0.0, 1.0), + }, + }, + }, + } +} diff --git a/float32validator/between_test.go b/float32validator/between_test.go new file mode 100644 index 0000000..1d3b813 --- /dev/null +++ b/float32validator/between_test.go @@ -0,0 +1,92 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float32validator_test + +import ( + "context" + "testing" + + "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/float32validator" +) + +func TestBetweenValidator(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.Float32 + min float32 + max float32 + expectError bool + } + tests := map[string]testCase{ + "unknown Float32": { + val: types.Float32Unknown(), + min: 0.90, + max: 3.10, + }, + "null Float32": { + val: types.Float32Null(), + min: 0.90, + max: 3.10, + }, + "valid integer as Float32": { + val: types.Float32Value(2), + min: 0.90, + max: 3.10, + }, + "valid float as Float32": { + val: types.Float32Value(2.2), + min: 0.90, + max: 3.10, + }, + "valid float as Float32 min": { + val: types.Float32Value(0.9), + min: 0.90, + max: 3.10, + }, + "valid float as Float32 max": { + val: types.Float32Value(3.1), + min: 0.90, + max: 3.10, + }, + "too small float as Float32": { + val: types.Float32Value(-1.1111), + min: 0.90, + max: 3.10, + expectError: true, + }, + "too large float as Float32": { + val: types.Float32Value(4.2), + min: 0.90, + max: 3.10, + expectError: true, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + request := validator.Float32Request{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + } + response := validator.Float32Response{} + float32validator.Between(test.min, test.max).ValidateFloat32(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/float32validator/conflicts_with.go b/float32validator/conflicts_with.go new file mode 100644 index 0000000..4b24668 --- /dev/null +++ b/float32validator/conflicts_with.go @@ -0,0 +1,28 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float32validator + +import ( + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" +) + +// 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.Float32 { + return schemavalidator.ConflictsWithValidator{ + PathExpressions: expressions, + } +} diff --git a/float32validator/conflicts_with_example_test.go b/float32validator/conflicts_with_example_test.go new file mode 100644 index 0000000..5631848 --- /dev/null +++ b/float32validator/conflicts_with_example_test.go @@ -0,0 +1,32 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float32validator_test + +import ( + "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-validators/float32validator" +) + +func ExampleConflictsWith() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Float32Attribute{ + Optional: true, + Validators: []validator.Float32{ + // Validate this attribute must not be configured with other_attr. + float32validator.ConflictsWith(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.StringAttribute{ + Optional: true, + }, + }, + } +} diff --git a/float32validator/doc.go b/float32validator/doc.go new file mode 100644 index 0000000..3b0a7c1 --- /dev/null +++ b/float32validator/doc.go @@ -0,0 +1,5 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +// Package float32validator provides validators for types.Float32 attributes. +package float32validator diff --git a/float32validator/exactly_one_of.go b/float32validator/exactly_one_of.go new file mode 100644 index 0000000..7f12dd7 --- /dev/null +++ b/float32validator/exactly_one_of.go @@ -0,0 +1,29 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float32validator + +import ( + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" +) + +// 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.Float32 { + return schemavalidator.ExactlyOneOfValidator{ + PathExpressions: expressions, + } +} diff --git a/float32validator/exactly_one_of_example_test.go b/float32validator/exactly_one_of_example_test.go new file mode 100644 index 0000000..03a151c --- /dev/null +++ b/float32validator/exactly_one_of_example_test.go @@ -0,0 +1,32 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float32validator_test + +import ( + "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-validators/float32validator" +) + +func ExampleExactlyOneOf() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Float32Attribute{ + Optional: true, + Validators: []validator.Float32{ + // Validate only this attribute or other_attr is configured. + float32validator.ExactlyOneOf(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.StringAttribute{ + Optional: true, + }, + }, + } +} diff --git a/float32validator/none_of.go b/float32validator/none_of.go new file mode 100644 index 0000000..fcaef90 --- /dev/null +++ b/float32validator/none_of.go @@ -0,0 +1,65 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float32validator + +import ( + "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" +) + +var _ validator.Float32 = noneOfValidator{} + +// noneOfValidator validates that the value does not match one of the values. +type noneOfValidator struct { + values []types.Float32 +} + +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) ValidateFloat32(ctx context.Context, request validator.Float32Request, response *validator.Float32Response) { + 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 float32 held in the attribute +// is none of the given `values`. +func NoneOf(values ...float32) validator.Float32 { + frameworkValues := make([]types.Float32, 0, len(values)) + + for _, value := range values { + frameworkValues = append(frameworkValues, types.Float32Value(value)) + } + + return noneOfValidator{ + values: frameworkValues, + } +} diff --git a/float32validator/none_of_example_test.go b/float32validator/none_of_example_test.go new file mode 100644 index 0000000..7da6010 --- /dev/null +++ b/float32validator/none_of_example_test.go @@ -0,0 +1,26 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float32validator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + + "github.com/hashicorp/terraform-plugin-framework-validators/float32validator" +) + +func ExampleNoneOf() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Float32Attribute{ + Required: true, + Validators: []validator.Float32{ + // Validate floating point value must not be 1.2, 2.4, or 4.8 + float32validator.NoneOf([]float32{1.2, 2.4, 4.8}...), + }, + }, + }, + } +} diff --git a/float32validator/none_of_test.go b/float32validator/none_of_test.go new file mode 100644 index 0000000..c2a3d5f --- /dev/null +++ b/float32validator/none_of_test.go @@ -0,0 +1,88 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float32validator_test + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/float32validator" +) + +func TestNoneOfValidator(t *testing.T) { + t.Parallel() + + type testCase struct { + in types.Float32 + validator validator.Float32 + expErrors int + } + + testCases := map[string]testCase{ + "simple-match": { + in: types.Float32Value(123.456), + validator: float32validator.NoneOf( + 123.456, + 234.567, + 8910.11, + 1213.1415, + ), + expErrors: 1, + }, + "simple-mismatch": { + in: types.Float32Value(123.456), + validator: float32validator.NoneOf( + 234.567, + 8910.11, + 1213.1415, + ), + expErrors: 0, + }, + "skip-validation-on-null": { + in: types.Float32Null(), + validator: float32validator.NoneOf( + 234.567, + 8910.11, + 1213.1415, + ), + expErrors: 0, + }, + "skip-validation-on-unknown": { + in: types.Float32Unknown(), + validator: float32validator.NoneOf( + 234.567, + 8910.11, + 1213.1415, + ), + expErrors: 0, + }, + } + + for name, test := range testCases { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + req := validator.Float32Request{ + ConfigValue: test.in, + } + res := validator.Float32Response{} + test.validator.ValidateFloat32(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/float32validator/one_of.go b/float32validator/one_of.go new file mode 100644 index 0000000..c09543b --- /dev/null +++ b/float32validator/one_of.go @@ -0,0 +1,63 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float32validator + +import ( + "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" +) + +var _ validator.Float32 = oneOfValidator{} + +// oneOfValidator validates that the value matches one of expected values. +type oneOfValidator struct { + values []types.Float32 +} + +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) ValidateFloat32(ctx context.Context, request validator.Float32Request, response *validator.Float32Response) { + 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 float32 held in the attribute +// is one of the given `values`. +func OneOf(values ...float32) validator.Float32 { + frameworkValues := make([]types.Float32, 0, len(values)) + + for _, value := range values { + frameworkValues = append(frameworkValues, types.Float32Value(value)) + } + + return oneOfValidator{ + values: frameworkValues, + } +} diff --git a/float32validator/one_of_example_test.go b/float32validator/one_of_example_test.go new file mode 100644 index 0000000..7c9e942 --- /dev/null +++ b/float32validator/one_of_example_test.go @@ -0,0 +1,26 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float32validator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + + "github.com/hashicorp/terraform-plugin-framework-validators/float32validator" +) + +func ExampleOneOf() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Float32Attribute{ + Required: true, + Validators: []validator.Float32{ + // Validate floating point value must be 1.2, 2.4, or 4.8 + float32validator.OneOf([]float32{1.2, 2.4, 4.8}...), + }, + }, + }, + } +} diff --git a/float32validator/one_of_test.go b/float32validator/one_of_test.go new file mode 100644 index 0000000..cfe5961 --- /dev/null +++ b/float32validator/one_of_test.go @@ -0,0 +1,88 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float32validator_test + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/float32validator" +) + +func TestOneOfValidator(t *testing.T) { + t.Parallel() + + type testCase struct { + in types.Float32 + validator validator.Float32 + expErrors int + } + + testCases := map[string]testCase{ + "simple-match": { + in: types.Float32Value(123.456), + validator: float32validator.OneOf( + 123.456, + 234.567, + 8910.11, + 1213.1415, + ), + expErrors: 0, + }, + "simple-mismatch": { + in: types.Float32Value(123.456), + validator: float32validator.OneOf( + 234.567, + 8910.11, + 1213.1415, + ), + expErrors: 1, + }, + "skip-validation-on-null": { + in: types.Float32Null(), + validator: float32validator.OneOf( + 234.567, + 8910.11, + 1213.1415, + ), + expErrors: 0, + }, + "skip-validation-on-unknown": { + in: types.Float32Unknown(), + validator: float32validator.OneOf( + 234.567, + 8910.11, + 1213.1415, + ), + expErrors: 0, + }, + } + + for name, test := range testCases { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + req := validator.Float32Request{ + ConfigValue: test.in, + } + res := validator.Float32Response{} + test.validator.ValidateFloat32(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/int32validator/all.go b/int32validator/all.go new file mode 100644 index 0000000..f5cd0ed --- /dev/null +++ b/int32validator/all.go @@ -0,0 +1,57 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32validator + +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.Int32) validator.Int32 { + return allValidator{ + validators: validators, + } +} + +var _ validator.Int32 = allValidator{} + +// allValidator implements the validator. +type allValidator struct { + validators []validator.Int32 +} + +// 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) +} + +// ValidateInt32 performs the validation. +func (v allValidator) ValidateInt32(ctx context.Context, req validator.Int32Request, resp *validator.Int32Response) { + for _, subValidator := range v.validators { + validateResp := &validator.Int32Response{} + + subValidator.ValidateInt32(ctx, req, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) + } +} diff --git a/int32validator/all_example_test.go b/int32validator/all_example_test.go new file mode 100644 index 0000000..db508b1 --- /dev/null +++ b/int32validator/all_example_test.go @@ -0,0 +1,34 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32validator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + + "github.com/hashicorp/terraform-plugin-framework-validators/int32validator" +) + +func ExampleAll() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Int32Attribute{ + Required: true, + Validators: []validator.Int32{ + // Validate this Int32 value must either be: + // - 1 + // - At least 2, but not 3 + int32validator.Any( + int32validator.OneOf(1), + int32validator.All( + int32validator.AtLeast(2), + int32validator.NoneOf(3), + ), + ), + }, + }, + }, + } +} diff --git a/int32validator/all_test.go b/int32validator/all_test.go new file mode 100644 index 0000000..ff1266e --- /dev/null +++ b/int32validator/all_test.go @@ -0,0 +1,74 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32validator_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/int32validator" +) + +func TestAllValidatorValidateInt32(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.Int32 + validators []validator.Int32 + expected diag.Diagnostics + } + tests := map[string]testCase{ + "invalid": { + val: types.Int32Value(1), + validators: []validator.Int32{ + int32validator.AtLeast(3), + int32validator.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.Int32Value(1), + validators: []validator.Int32{ + int32validator.AtLeast(0), + int32validator.AtLeast(1), + }, + expected: nil, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + request := validator.Int32Request{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + } + response := validator.Int32Response{} + int32validator.All(test.validators...).ValidateInt32(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, test.expected); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/int32validator/also_requires.go b/int32validator/also_requires.go new file mode 100644 index 0000000..f886e1c --- /dev/null +++ b/int32validator/also_requires.go @@ -0,0 +1,27 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32validator + +import ( + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" +) + +// 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.Int32 { + return schemavalidator.AlsoRequiresValidator{ + PathExpressions: expressions, + } +} diff --git a/int32validator/also_requires_example_test.go b/int32validator/also_requires_example_test.go new file mode 100644 index 0000000..3af1016 --- /dev/null +++ b/int32validator/also_requires_example_test.go @@ -0,0 +1,32 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32validator_test + +import ( + "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-validators/int32validator" +) + +func ExampleAlsoRequires() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Int32Attribute{ + Optional: true, + Validators: []validator.Int32{ + // Validate this attribute must be configured with other_attr. + int32validator.AlsoRequires(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.StringAttribute{ + Optional: true, + }, + }, + } +} diff --git a/int32validator/any.go b/int32validator/any.go new file mode 100644 index 0000000..ba93969 --- /dev/null +++ b/int32validator/any.go @@ -0,0 +1,65 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32validator + +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.Int32) validator.Int32 { + return anyValidator{ + validators: validators, + } +} + +var _ validator.Int32 = anyValidator{} + +// anyValidator implements the validator. +type anyValidator struct { + validators []validator.Int32 +} + +// 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) +} + +// ValidateInt32 performs the validation. +func (v anyValidator) ValidateInt32(ctx context.Context, req validator.Int32Request, resp *validator.Int32Response) { + for _, subValidator := range v.validators { + validateResp := &validator.Int32Response{} + + subValidator.ValidateInt32(ctx, req, validateResp) + + if !validateResp.Diagnostics.HasError() { + resp.Diagnostics = validateResp.Diagnostics + + return + } + + resp.Diagnostics.Append(validateResp.Diagnostics...) + } +} diff --git a/int32validator/any_example_test.go b/int32validator/any_example_test.go new file mode 100644 index 0000000..89a8016 --- /dev/null +++ b/int32validator/any_example_test.go @@ -0,0 +1,31 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32validator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + + "github.com/hashicorp/terraform-plugin-framework-validators/int32validator" +) + +func ExampleAny() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Int32Attribute{ + Required: true, + Validators: []validator.Int32{ + // Validate this Int32 value must either be: + // - 1 + // - At least 2 + int32validator.Any( + int32validator.OneOf(1), + int32validator.AtLeast(2), + ), + }, + }, + }, + } +} diff --git a/int32validator/any_test.go b/int32validator/any_test.go new file mode 100644 index 0000000..3bce8de --- /dev/null +++ b/int32validator/any_test.go @@ -0,0 +1,85 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32validator_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/int32validator" + "github.com/hashicorp/terraform-plugin-framework-validators/internal/testvalidator" +) + +func TestAnyValidatorValidateInt32(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.Int32 + validators []validator.Int32 + expected diag.Diagnostics + } + tests := map[string]testCase{ + "invalid": { + val: types.Int32Value(1), + validators: []validator.Int32{ + int32validator.AtLeast(3), + int32validator.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.Int32Value(4), + validators: []validator.Int32{ + int32validator.AtLeast(5), + int32validator.AtLeast(3), + }, + expected: diag.Diagnostics{}, + }, + "valid with warning": { + val: types.Int32Value(4), + validators: []validator.Int32{ + int32validator.All(int32validator.AtLeast(5), testvalidator.WarningInt32("failing warning summary", "failing warning details")), + int32validator.All(int32validator.AtLeast(2), testvalidator.WarningInt32("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) { + t.Parallel() + request := validator.Int32Request{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + } + response := validator.Int32Response{} + int32validator.Any(test.validators...).ValidateInt32(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, test.expected); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/int32validator/any_with_all_warnings.go b/int32validator/any_with_all_warnings.go new file mode 100644 index 0000000..1ddb261 --- /dev/null +++ b/int32validator/any_with_all_warnings.go @@ -0,0 +1,67 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32validator + +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.Int32) validator.Int32 { + return anyWithAllWarningsValidator{ + validators: validators, + } +} + +var _ validator.Int32 = anyWithAllWarningsValidator{} + +// anyWithAllWarningsValidator implements the validator. +type anyWithAllWarningsValidator struct { + validators []validator.Int32 +} + +// 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) +} + +// ValidateInt32 performs the validation. +func (v anyWithAllWarningsValidator) ValidateInt32(ctx context.Context, req validator.Int32Request, resp *validator.Int32Response) { + anyValid := false + + for _, subValidator := range v.validators { + validateResp := &validator.Int32Response{} + + subValidator.ValidateInt32(ctx, req, validateResp) + + if !validateResp.Diagnostics.HasError() { + anyValid = true + } + + resp.Diagnostics.Append(validateResp.Diagnostics...) + } + + if anyValid { + resp.Diagnostics = resp.Diagnostics.Warnings() + } +} diff --git a/int32validator/any_with_all_warnings_example_test.go b/int32validator/any_with_all_warnings_example_test.go new file mode 100644 index 0000000..7c6ce12 --- /dev/null +++ b/int32validator/any_with_all_warnings_example_test.go @@ -0,0 +1,31 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32validator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + + "github.com/hashicorp/terraform-plugin-framework-validators/int32validator" +) + +func ExampleAnyWithAllWarnings() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Int32Attribute{ + Required: true, + Validators: []validator.Int32{ + // Validate this Int32 value must either be: + // - 1 + // - At least 2 + int32validator.AnyWithAllWarnings( + int32validator.OneOf(1), + int32validator.AtLeast(2), + ), + }, + }, + }, + } +} diff --git a/int32validator/any_with_all_warnings_test.go b/int32validator/any_with_all_warnings_test.go new file mode 100644 index 0000000..cc84c96 --- /dev/null +++ b/int32validator/any_with_all_warnings_test.go @@ -0,0 +1,86 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32validator_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/int32validator" + "github.com/hashicorp/terraform-plugin-framework-validators/internal/testvalidator" +) + +func TestAnyWithAllWarningsValidatorValidateInt32(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.Int32 + validators []validator.Int32 + expected diag.Diagnostics + } + tests := map[string]testCase{ + "invalid": { + val: types.Int32Value(1), + validators: []validator.Int32{ + int32validator.AtLeast(3), + int32validator.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.Int32Value(4), + validators: []validator.Int32{ + int32validator.AtLeast(5), + int32validator.AtLeast(3), + }, + expected: diag.Diagnostics{}, + }, + "valid with warning": { + val: types.Int32Value(4), + validators: []validator.Int32{ + int32validator.All(int32validator.AtLeast(5), testvalidator.WarningInt32("failing warning summary", "failing warning details")), + int32validator.All(int32validator.AtLeast(2), testvalidator.WarningInt32("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) { + t.Parallel() + request := validator.Int32Request{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + } + response := validator.Int32Response{} + int32validator.AnyWithAllWarnings(test.validators...).ValidateInt32(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, test.expected); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/int32validator/at_least.go b/int32validator/at_least.go new file mode 100644 index 0000000..5aa839e --- /dev/null +++ b/int32validator/at_least.go @@ -0,0 +1,58 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32validator + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + + "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" +) + +var _ validator.Int32 = atLeastValidator{} + +// atLeastValidator validates that an integer Attribute's value is at least a certain value. +type atLeastValidator struct { + min int32 +} + +// Description describes the validation in plain text formatting. +func (validator atLeastValidator) Description(_ context.Context) string { + return fmt.Sprintf("value must be at least %d", validator.min) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (validator atLeastValidator) MarkdownDescription(ctx context.Context) string { + return validator.Description(ctx) +} + +// ValidateInt32 performs the validation. +func (v atLeastValidator) ValidateInt32(ctx context.Context, request validator.Int32Request, response *validator.Int32Response) { + if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() { + return + } + + if request.ConfigValue.ValueInt32() < v.min { + response.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( + request.Path, + v.Description(ctx), + fmt.Sprintf("%d", request.ConfigValue.ValueInt32()), + )) + } +} + +// AtLeast returns an AttributeValidator which ensures that any configured +// attribute value: +// +// - Is a number, which can be represented by a 32-bit integer. +// - Is greater than or equal to the given minimum. +// +// Null (unconfigured) and unknown (known after apply) values are skipped. +func AtLeast(min int32) validator.Int32 { + return atLeastValidator{ + min: min, + } +} diff --git a/int32validator/at_least_example_test.go b/int32validator/at_least_example_test.go new file mode 100644 index 0000000..d97197b --- /dev/null +++ b/int32validator/at_least_example_test.go @@ -0,0 +1,26 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32validator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + + "github.com/hashicorp/terraform-plugin-framework-validators/int32validator" +) + +func ExampleAtLeast() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Int32Attribute{ + Required: true, + Validators: []validator.Int32{ + // Validate integer value must be at least 42 + int32validator.AtLeast(42), + }, + }, + }, + } +} diff --git a/int32validator/at_least_one_of.go b/int32validator/at_least_one_of.go new file mode 100644 index 0000000..16eba3f --- /dev/null +++ b/int32validator/at_least_one_of.go @@ -0,0 +1,28 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32validator + +import ( + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" +) + +// 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.Int32 { + return schemavalidator.AtLeastOneOfValidator{ + PathExpressions: expressions, + } +} diff --git a/int32validator/at_least_one_of_example_test.go b/int32validator/at_least_one_of_example_test.go new file mode 100644 index 0000000..17f7ee9 --- /dev/null +++ b/int32validator/at_least_one_of_example_test.go @@ -0,0 +1,32 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32validator_test + +import ( + "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-validators/int32validator" +) + +func ExampleAtLeastOneOf() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Int32Attribute{ + Optional: true, + Validators: []validator.Int32{ + // Validate at least this attribute or other_attr should be configured. + int32validator.AtLeastOneOf(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.StringAttribute{ + Optional: true, + }, + }, + } +} diff --git a/int32validator/at_least_sum_of.go b/int32validator/at_least_sum_of.go new file mode 100644 index 0000000..46808c6 --- /dev/null +++ b/int32validator/at_least_sum_of.go @@ -0,0 +1,116 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32validator + +import ( + "context" + "fmt" + "strings" + + "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 _ validator.Int32 = 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. +type atLeastSumOfValidator struct { + attributesToSumPathExpressions path.Expressions +} + +// Description describes the validation in plain text formatting. +func (av atLeastSumOfValidator) Description(_ context.Context) string { + var attributePaths []string + for _, p := range av.attributesToSumPathExpressions { + attributePaths = append(attributePaths, p.String()) + } + + return fmt.Sprintf("value must be at least sum of %s", strings.Join(attributePaths, " + ")) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (av atLeastSumOfValidator) MarkdownDescription(ctx context.Context) string { + return av.Description(ctx) +} + +// ValidateInt32 performs the validation. +func (av atLeastSumOfValidator) ValidateInt32(ctx context.Context, request validator.Int32Request, response *validator.Int32Response) { + if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() { + return + } + + // Ensure input path expressions resolution against the current attribute + expressions := request.PathExpression.MergeExpressions(av.attributesToSumPathExpressions...) + + // Sum the value of all the attributes involved, but only if they are all known. + var sumOfAttribs int32 + for _, expression := range expressions { + matchedPaths, diags := request.Config.PathMatches(ctx, expression) + response.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(request.Path) { + continue + } + + // Get the value + var matchedValue attr.Value + diags := request.Config.GetAttribute(ctx, mp, &matchedValue) + response.Diagnostics.Append(diags...) + if diags.HasError() { + continue + } + + if matchedValue.IsUnknown() { + return + } + + if matchedValue.IsNull() { + continue + } + + // We know there is a value, convert it to the expected type + var attribToSum types.Int32 + diags = tfsdk.ValueAs(ctx, matchedValue, &attribToSum) + response.Diagnostics.Append(diags...) + if diags.HasError() { + continue + } + + sumOfAttribs += attribToSum.ValueInt32() + } + } + + if request.ConfigValue.ValueInt32() < sumOfAttribs { + response.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( + request.Path, + av.Description(ctx), + fmt.Sprintf("%d", request.ConfigValue.ValueInt32()), + )) + } +} + +// AtLeastSumOf returns an AttributeValidator which ensures that any configured +// attribute value: +// +// - Is a number, which can be represented by a 32-bit integer. +// - 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) validator.Int32 { + return atLeastSumOfValidator{attributesToSumPathExpressions} +} diff --git a/int32validator/at_least_sum_of_example_test.go b/int32validator/at_least_sum_of_example_test.go new file mode 100644 index 0000000..a8b56c1 --- /dev/null +++ b/int32validator/at_least_sum_of_example_test.go @@ -0,0 +1,37 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32validator_test + +import ( + "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-validators/int32validator" +) + +func ExampleAtLeastSumOf() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Int32Attribute{ + Required: true, + Validators: []validator.Int32{ + // Validate this integer value must be at least the + // summed integer values of other_attr1 and other_attr2. + int32validator.AtLeastSumOf(path.Expressions{ + path.MatchRoot("other_attr1"), + path.MatchRoot("other_attr2"), + }...), + }, + }, + "other_attr1": schema.Int32Attribute{ + Required: true, + }, + "other_attr2": schema.Int32Attribute{ + Required: true, + }, + }, + } +} diff --git a/int32validator/at_least_sum_of_test.go b/int32validator/at_least_sum_of_test.go new file mode 100644 index 0000000..8126c96 --- /dev/null +++ b/int32validator/at_least_sum_of_test.go @@ -0,0 +1,182 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32validator + +import ( + "context" + "testing" + + "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" +) + +func TestAtLeastSumOfValidator(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.Int32 + attributesToSumExpressions path.Expressions + requestConfigRaw map[string]tftypes.Value + expectError bool + } + tests := map[string]testCase{ + "unknown Int32": { + val: types.Int32Unknown(), + }, + "null Int32": { + val: types.Int32Null(), + }, + "valid integer as Int32 less than sum of attributes": { + val: types.Int32Value(10), + attributesToSumExpressions: path.Expressions{ + path.MatchRoot("one"), + path.MatchRoot("two"), + }, + requestConfigRaw: map[string]tftypes.Value{ + "one": tftypes.NewValue(tftypes.Number, 15), + "two": tftypes.NewValue(tftypes.Number, 15), + }, + expectError: true, + }, + "valid integer as Int32 equal to sum of attributes": { + val: types.Int32Value(10), + attributesToSumExpressions: path.Expressions{ + path.MatchRoot("one"), + path.MatchRoot("two"), + }, + requestConfigRaw: map[string]tftypes.Value{ + "one": tftypes.NewValue(tftypes.Number, 5), + "two": tftypes.NewValue(tftypes.Number, 5), + }, + }, + "valid integer as Int32 greater than sum of attributes": { + val: types.Int32Value(10), + attributesToSumExpressions: path.Expressions{ + path.MatchRoot("one"), + path.MatchRoot("two"), + }, + requestConfigRaw: map[string]tftypes.Value{ + "one": tftypes.NewValue(tftypes.Number, 4), + "two": tftypes.NewValue(tftypes.Number, 4), + }, + }, + "valid integer as Int32 greater than sum of attributes, when one summed attribute is null": { + val: types.Int32Value(10), + attributesToSumExpressions: path.Expressions{ + path.MatchRoot("one"), + path.MatchRoot("two"), + }, + requestConfigRaw: map[string]tftypes.Value{ + "one": tftypes.NewValue(tftypes.Number, nil), + "two": tftypes.NewValue(tftypes.Number, 9), + }, + }, + "valid integer as Int32 does not return error when all attributes are null": { + val: types.Int32Null(), + attributesToSumExpressions: path.Expressions{ + path.MatchRoot("one"), + path.MatchRoot("two"), + }, + requestConfigRaw: map[string]tftypes.Value{ + "one": tftypes.NewValue(tftypes.Number, nil), + "two": tftypes.NewValue(tftypes.Number, nil), + }, + }, + "valid integer as Int32 returns error when all attributes to sum are null": { + val: types.Int32Value(-1), + attributesToSumExpressions: path.Expressions{ + path.MatchRoot("one"), + path.MatchRoot("two"), + }, + requestConfigRaw: map[string]tftypes.Value{ + "one": tftypes.NewValue(tftypes.Number, nil), + "two": tftypes.NewValue(tftypes.Number, nil), + }, + expectError: true, + }, + "valid integer as Int32 greater than sum of attributes, when one summed attribute is unknown": { + val: types.Int32Value(10), + attributesToSumExpressions: path.Expressions{ + path.MatchRoot("one"), + path.MatchRoot("two"), + }, + requestConfigRaw: map[string]tftypes.Value{ + "one": tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), + "two": tftypes.NewValue(tftypes.Number, 9), + }, + }, + "valid integer as Int32 does not return error when all attributes are unknown": { + val: types.Int32Unknown(), + attributesToSumExpressions: path.Expressions{ + path.MatchRoot("one"), + path.MatchRoot("two"), + }, + requestConfigRaw: map[string]tftypes.Value{ + "one": tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), + "two": tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), + }, + }, + "valid integer as Int32 does not return error when all attributes to sum are unknown": { + val: types.Int32Value(-1), + attributesToSumExpressions: path.Expressions{ + path.MatchRoot("one"), + path.MatchRoot("two"), + }, + requestConfigRaw: map[string]tftypes.Value{ + "one": tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), + "two": tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), + }, + }, + "error when attribute to sum is not Number": { + val: types.Int32Value(9), + attributesToSumExpressions: path.Expressions{ + path.MatchRoot("one"), + path.MatchRoot("two"), + }, + requestConfigRaw: map[string]tftypes.Value{ + "one": tftypes.NewValue(tftypes.Bool, true), + "two": tftypes.NewValue(tftypes.Number, 9), + }, + expectError: true, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + request := validator.Int32Request{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + Config: tfsdk.Config{ + Raw: tftypes.NewValue(tftypes.Object{}, test.requestConfigRaw), + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.Int32Attribute{}, + "one": schema.Int32Attribute{}, + "two": schema.Int32Attribute{}, + }, + }, + }, + } + + response := validator.Int32Response{} + + AtLeastSumOf(test.attributesToSumExpressions...).ValidateInt32(context.Background(), 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/int32validator/at_least_test.go b/int32validator/at_least_test.go new file mode 100644 index 0000000..d6e875b --- /dev/null +++ b/int32validator/at_least_test.go @@ -0,0 +1,70 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32validator_test + +import ( + "context" + "testing" + + "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/int32validator" +) + +func TestAtLeastValidator(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.Int32 + min int32 + expectError bool + } + tests := map[string]testCase{ + "unknown Int32": { + val: types.Int32Unknown(), + min: 1, + }, + "null Int32": { + val: types.Int32Null(), + min: 1, + }, + "valid integer as Int32": { + val: types.Int32Value(2), + min: 1, + }, + "valid integer as Int32 min": { + val: types.Int32Value(1), + min: 1, + }, + "too small integer as Int32": { + val: types.Int32Value(-1), + min: 1, + expectError: true, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + request := validator.Int32Request{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + } + response := validator.Int32Response{} + int32validator.AtLeast(test.min).ValidateInt32(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/int32validator/at_most.go b/int32validator/at_most.go new file mode 100644 index 0000000..59ad7a5 --- /dev/null +++ b/int32validator/at_most.go @@ -0,0 +1,58 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32validator + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + + "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" +) + +var _ validator.Int32 = atMostValidator{} + +// atMostValidator validates that an integer Attribute's value is at most a certain value. +type atMostValidator struct { + max int32 +} + +// Description describes the validation in plain text formatting. +func (validator atMostValidator) Description(_ context.Context) string { + return fmt.Sprintf("value must be at most %d", validator.max) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (validator atMostValidator) MarkdownDescription(ctx context.Context) string { + return validator.Description(ctx) +} + +// ValidateInt32 performs the validation. +func (v atMostValidator) ValidateInt32(ctx context.Context, request validator.Int32Request, response *validator.Int32Response) { + if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() { + return + } + + if request.ConfigValue.ValueInt32() > v.max { + response.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( + request.Path, + v.Description(ctx), + fmt.Sprintf("%d", request.ConfigValue.ValueInt32()), + )) + } +} + +// AtMost returns an AttributeValidator which ensures that any configured +// attribute value: +// +// - Is a number, which can be represented by a 32-bit integer. +// - Is less than or equal to the given maximum. +// +// Null (unconfigured) and unknown (known after apply) values are skipped. +func AtMost(max int32) validator.Int32 { + return atMostValidator{ + max: max, + } +} diff --git a/int32validator/at_most_example_test.go b/int32validator/at_most_example_test.go new file mode 100644 index 0000000..af49a23 --- /dev/null +++ b/int32validator/at_most_example_test.go @@ -0,0 +1,26 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32validator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + + "github.com/hashicorp/terraform-plugin-framework-validators/int32validator" +) + +func ExampleAtMost() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Int32Attribute{ + Required: true, + Validators: []validator.Int32{ + // Validate integer value must be at most 42 + int32validator.AtMost(42), + }, + }, + }, + } +} diff --git a/int32validator/at_most_sum_of.go b/int32validator/at_most_sum_of.go new file mode 100644 index 0000000..d59ed3f --- /dev/null +++ b/int32validator/at_most_sum_of.go @@ -0,0 +1,116 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32validator + +import ( + "context" + "fmt" + "strings" + + "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 _ validator.Int32 = 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. +type atMostSumOfValidator struct { + attributesToSumPathExpressions path.Expressions +} + +// Description describes the validation in plain text formatting. +func (av atMostSumOfValidator) Description(_ context.Context) string { + var attributePaths []string + for _, p := range av.attributesToSumPathExpressions { + attributePaths = append(attributePaths, p.String()) + } + + return fmt.Sprintf("value must be at most sum of %s", strings.Join(attributePaths, " + ")) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (av atMostSumOfValidator) MarkdownDescription(ctx context.Context) string { + return av.Description(ctx) +} + +// ValidateInt32 performs the validation. +func (av atMostSumOfValidator) ValidateInt32(ctx context.Context, request validator.Int32Request, response *validator.Int32Response) { + if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() { + return + } + + // Ensure input path expressions resolution against the current attribute + expressions := request.PathExpression.MergeExpressions(av.attributesToSumPathExpressions...) + + // Sum the value of all the attributes involved, but only if they are all known. + var sumOfAttribs int32 + for _, expression := range expressions { + matchedPaths, diags := request.Config.PathMatches(ctx, expression) + response.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(request.Path) { + continue + } + + // Get the value + var matchedValue attr.Value + diags := request.Config.GetAttribute(ctx, mp, &matchedValue) + response.Diagnostics.Append(diags...) + if diags.HasError() { + continue + } + + if matchedValue.IsUnknown() { + return + } + + if matchedValue.IsNull() { + continue + } + + // We know there is a value, convert it to the expected type + var attribToSum types.Int32 + diags = tfsdk.ValueAs(ctx, matchedValue, &attribToSum) + response.Diagnostics.Append(diags...) + if diags.HasError() { + continue + } + + sumOfAttribs += attribToSum.ValueInt32() + } + } + + if request.ConfigValue.ValueInt32() > sumOfAttribs { + response.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( + request.Path, + av.Description(ctx), + fmt.Sprintf("%d", request.ConfigValue.ValueInt32()), + )) + } +} + +// AtMostSumOf returns an AttributeValidator which ensures that any configured +// attribute value: +// +// - Is a number, which can be represented by a 32-bit integer. +// - 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) validator.Int32 { + return atMostSumOfValidator{attributesToSumPathExpressions} +} diff --git a/int32validator/at_most_sum_of_example_test.go b/int32validator/at_most_sum_of_example_test.go new file mode 100644 index 0000000..1dbad05 --- /dev/null +++ b/int32validator/at_most_sum_of_example_test.go @@ -0,0 +1,37 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32validator_test + +import ( + "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-validators/int32validator" +) + +func ExampleAtMostSumOf() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Int32Attribute{ + Required: true, + Validators: []validator.Int32{ + // Validate this integer value must be at most the + // summed integer values of other_attr1 and other_attr2. + int32validator.AtMostSumOf(path.Expressions{ + path.MatchRoot("other_attr1"), + path.MatchRoot("other_attr2"), + }...), + }, + }, + "other_attr1": schema.Int32Attribute{ + Required: true, + }, + "other_attr2": schema.Int32Attribute{ + Required: true, + }, + }, + } +} diff --git a/int32validator/at_most_sum_of_test.go b/int32validator/at_most_sum_of_test.go new file mode 100644 index 0000000..da63c63 --- /dev/null +++ b/int32validator/at_most_sum_of_test.go @@ -0,0 +1,182 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32validator + +import ( + "context" + "testing" + + "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" +) + +func TestAtMostSumOfValidator(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.Int32 + attributesToSumPathExpressions path.Expressions + requestConfigRaw map[string]tftypes.Value + expectError bool + } + tests := map[string]testCase{ + "unknown Int32": { + val: types.Int32Unknown(), + }, + "null Int32": { + val: types.Int32Null(), + }, + "valid integer as Int32 more than sum of attributes": { + val: types.Int32Value(11), + attributesToSumPathExpressions: path.Expressions{ + path.MatchRoot("one"), + path.MatchRoot("two"), + }, + requestConfigRaw: map[string]tftypes.Value{ + "one": tftypes.NewValue(tftypes.Number, 5), + "two": tftypes.NewValue(tftypes.Number, 5), + }, + expectError: true, + }, + "valid integer as Int32 equal to sum of attributes": { + val: types.Int32Value(10), + attributesToSumPathExpressions: path.Expressions{ + path.MatchRoot("one"), + path.MatchRoot("two"), + }, + requestConfigRaw: map[string]tftypes.Value{ + "one": tftypes.NewValue(tftypes.Number, 5), + "two": tftypes.NewValue(tftypes.Number, 5), + }, + }, + "valid integer as Int32 less than sum of attributes": { + val: types.Int32Value(7), + attributesToSumPathExpressions: path.Expressions{ + path.MatchRoot("one"), + path.MatchRoot("two"), + }, + requestConfigRaw: map[string]tftypes.Value{ + "one": tftypes.NewValue(tftypes.Number, 4), + "two": tftypes.NewValue(tftypes.Number, 4), + }, + }, + "valid integer as Int32 less than sum of attributes, when one summed attribute is null": { + val: types.Int32Value(8), + attributesToSumPathExpressions: path.Expressions{ + path.MatchRoot("one"), + path.MatchRoot("two"), + }, + requestConfigRaw: map[string]tftypes.Value{ + "one": tftypes.NewValue(tftypes.Number, nil), + "two": tftypes.NewValue(tftypes.Number, 9), + }, + }, + "valid integer as Int32 does not return error when all attributes are null": { + val: types.Int32Null(), + attributesToSumPathExpressions: path.Expressions{ + path.MatchRoot("one"), + path.MatchRoot("two"), + }, + requestConfigRaw: map[string]tftypes.Value{ + "one": tftypes.NewValue(tftypes.Number, nil), + "two": tftypes.NewValue(tftypes.Number, nil), + }, + }, + "valid integer as Int32 returns error when all attributes to sum are null": { + val: types.Int32Value(1), + attributesToSumPathExpressions: path.Expressions{ + path.MatchRoot("one"), + path.MatchRoot("two"), + }, + requestConfigRaw: map[string]tftypes.Value{ + "one": tftypes.NewValue(tftypes.Number, nil), + "two": tftypes.NewValue(tftypes.Number, nil), + }, + expectError: true, + }, + "valid integer as Int32 less than sum of attributes, when one summed attribute is unknown": { + val: types.Int32Value(8), + attributesToSumPathExpressions: path.Expressions{ + path.MatchRoot("one"), + path.MatchRoot("two"), + }, + requestConfigRaw: map[string]tftypes.Value{ + "one": tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), + "two": tftypes.NewValue(tftypes.Number, 9), + }, + }, + "valid integer as Int32 does not return error when all attributes are unknown": { + val: types.Int32Unknown(), + attributesToSumPathExpressions: path.Expressions{ + path.MatchRoot("one"), + path.MatchRoot("two"), + }, + requestConfigRaw: map[string]tftypes.Value{ + "one": tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), + "two": tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), + }, + }, + "valid integer as Int32 does not return error when all attributes to sum are unknown": { + val: types.Int32Value(1), + attributesToSumPathExpressions: path.Expressions{ + path.MatchRoot("one"), + path.MatchRoot("two"), + }, + requestConfigRaw: map[string]tftypes.Value{ + "one": tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), + "two": tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), + }, + }, + "error when attribute to sum is not Number": { + val: types.Int32Value(9), + attributesToSumPathExpressions: path.Expressions{ + path.MatchRoot("one"), + path.MatchRoot("two"), + }, + requestConfigRaw: map[string]tftypes.Value{ + "one": tftypes.NewValue(tftypes.Bool, true), + "two": tftypes.NewValue(tftypes.Number, 9), + }, + expectError: true, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + request := validator.Int32Request{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + Config: tfsdk.Config{ + Raw: tftypes.NewValue(tftypes.Object{}, test.requestConfigRaw), + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.Int32Attribute{}, + "one": schema.Int32Attribute{}, + "two": schema.Int32Attribute{}, + }, + }, + }, + } + + response := validator.Int32Response{} + + AtMostSumOf(test.attributesToSumPathExpressions...).ValidateInt32(context.Background(), 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/int32validator/at_most_test.go b/int32validator/at_most_test.go new file mode 100644 index 0000000..177b135 --- /dev/null +++ b/int32validator/at_most_test.go @@ -0,0 +1,70 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32validator_test + +import ( + "context" + "testing" + + "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/int32validator" +) + +func TestAtMostValidator(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.Int32 + max int32 + expectError bool + } + tests := map[string]testCase{ + "unknown Int32": { + val: types.Int32Unknown(), + max: 2, + }, + "null Int32": { + val: types.Int32Null(), + max: 2, + }, + "valid integer as Int32": { + val: types.Int32Value(1), + max: 2, + }, + "valid integer as Int32 min": { + val: types.Int32Value(2), + max: 2, + }, + "too large integer as Int32": { + val: types.Int32Value(4), + max: 2, + expectError: true, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + request := validator.Int32Request{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + } + response := validator.Int32Response{} + int32validator.AtMost(test.max).ValidateInt32(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/int32validator/between.go b/int32validator/between.go new file mode 100644 index 0000000..f17f37f --- /dev/null +++ b/int32validator/between.go @@ -0,0 +1,63 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32validator + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + + "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" +) + +var _ validator.Int32 = betweenValidator{} + +// betweenValidator validates that an integer Attribute's value is in a range. +type betweenValidator struct { + min, max int32 +} + +// Description describes the validation in plain text formatting. +func (validator betweenValidator) Description(_ context.Context) string { + return fmt.Sprintf("value must be between %d and %d", validator.min, validator.max) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (validator betweenValidator) MarkdownDescription(ctx context.Context) string { + return validator.Description(ctx) +} + +// ValidateInt32 performs the validation. +func (v betweenValidator) ValidateInt32(ctx context.Context, request validator.Int32Request, response *validator.Int32Response) { + if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() { + return + } + + if request.ConfigValue.ValueInt32() < v.min || request.ConfigValue.ValueInt32() > v.max { + response.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( + request.Path, + v.Description(ctx), + fmt.Sprintf("%d", request.ConfigValue.ValueInt32()), + )) + } +} + +// Between returns an AttributeValidator which ensures that any configured +// attribute value: +// +// - Is a number, which can be represented by a 32-bit integer. +// - 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 int32) validator.Int32 { + if min > max { + return nil + } + + return betweenValidator{ + min: min, + max: max, + } +} diff --git a/int32validator/between_example_test.go b/int32validator/between_example_test.go new file mode 100644 index 0000000..b0f8512 --- /dev/null +++ b/int32validator/between_example_test.go @@ -0,0 +1,26 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32validator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + + "github.com/hashicorp/terraform-plugin-framework-validators/int32validator" +) + +func ExampleBetween() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Int32Attribute{ + Required: true, + Validators: []validator.Int32{ + // Validate integer value must be at least 10 and at most 100 + int32validator.Between(10, 100), + }, + }, + }, + } +} diff --git a/int32validator/between_test.go b/int32validator/between_test.go new file mode 100644 index 0000000..8167ab4 --- /dev/null +++ b/int32validator/between_test.go @@ -0,0 +1,87 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32validator_test + +import ( + "context" + "testing" + + "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/int32validator" +) + +func TestBetweenValidator(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.Int32 + min int32 + max int32 + expectError bool + } + tests := map[string]testCase{ + "unknown Int32": { + val: types.Int32Unknown(), + min: 1, + max: 3, + }, + "null Int32": { + val: types.Int32Null(), + min: 1, + max: 3, + }, + "valid integer as Int32": { + val: types.Int32Value(2), + min: 1, + max: 3, + }, + "valid integer as Int32 min": { + val: types.Int32Value(1), + min: 1, + max: 3, + }, + "valid integer as Int32 max": { + val: types.Int32Value(3), + min: 1, + max: 3, + }, + "too small integer as Int32": { + val: types.Int32Value(-1), + min: 1, + max: 3, + expectError: true, + }, + "too large integer as Int32": { + val: types.Int32Value(42), + min: 1, + max: 3, + expectError: true, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + request := validator.Int32Request{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + } + response := validator.Int32Response{} + int32validator.Between(test.min, test.max).ValidateInt32(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/int32validator/conflicts_with.go b/int32validator/conflicts_with.go new file mode 100644 index 0000000..2f904d3 --- /dev/null +++ b/int32validator/conflicts_with.go @@ -0,0 +1,28 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32validator + +import ( + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" +) + +// 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.Int32 { + return schemavalidator.ConflictsWithValidator{ + PathExpressions: expressions, + } +} diff --git a/int32validator/conflicts_with_example_test.go b/int32validator/conflicts_with_example_test.go new file mode 100644 index 0000000..066a110 --- /dev/null +++ b/int32validator/conflicts_with_example_test.go @@ -0,0 +1,32 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32validator_test + +import ( + "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-validators/int32validator" +) + +func ExampleConflictsWith() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Int32Attribute{ + Optional: true, + Validators: []validator.Int32{ + // Validate this attribute must not be configured with other_attr. + int32validator.ConflictsWith(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.StringAttribute{ + Optional: true, + }, + }, + } +} diff --git a/int32validator/doc.go b/int32validator/doc.go new file mode 100644 index 0000000..c6403fb --- /dev/null +++ b/int32validator/doc.go @@ -0,0 +1,5 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +// Package int32validator provides validators for types.Int32 attributes. +package int32validator diff --git a/int32validator/equal_to_product_of.go b/int32validator/equal_to_product_of.go new file mode 100644 index 0000000..961884f --- /dev/null +++ b/int32validator/equal_to_product_of.go @@ -0,0 +1,116 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32validator + +import ( + "context" + "fmt" + "strings" + + "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 _ validator.Int32 = equalToProductOfValidator{} + +// equalToProductOfValidator validates that an integer Attribute's value equals the product of one +// or more integer Attributes retrieved via the given path expressions. +type equalToProductOfValidator struct { + attributesToMultiplyPathExpressions path.Expressions +} + +// Description describes the validation in plain text formatting. +func (av equalToProductOfValidator) Description(_ context.Context) string { + var attributePaths []string + for _, p := range av.attributesToMultiplyPathExpressions { + attributePaths = append(attributePaths, p.String()) + } + + return fmt.Sprintf("value must be equal to the product of %s", strings.Join(attributePaths, " + ")) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (av equalToProductOfValidator) MarkdownDescription(ctx context.Context) string { + return av.Description(ctx) +} + +// ValidateInt32 performs the validation. +func (av equalToProductOfValidator) ValidateInt32(ctx context.Context, request validator.Int32Request, response *validator.Int32Response) { + if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() { + return + } + + // Ensure input path expressions resolution against the current attribute + expressions := request.PathExpression.MergeExpressions(av.attributesToMultiplyPathExpressions...) + + // Multiply the value of all the attributes involved, but only if they are all known. + productOfAttribs := int32(1) + for _, expression := range expressions { + matchedPaths, diags := request.Config.PathMatches(ctx, expression) + response.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(request.Path) { + continue + } + + // Get the value + var matchedValue attr.Value + diags := request.Config.GetAttribute(ctx, mp, &matchedValue) + response.Diagnostics.Append(diags...) + if diags.HasError() { + continue + } + + if matchedValue.IsUnknown() { + return + } + + if matchedValue.IsNull() { + return + } + + // We know there is a value, convert it to the expected type + var attribToMultiply types.Int32 + diags = tfsdk.ValueAs(ctx, matchedValue, &attribToMultiply) + response.Diagnostics.Append(diags...) + if diags.HasError() { + continue + } + + productOfAttribs *= attribToMultiply.ValueInt32() + } + } + + if request.ConfigValue.ValueInt32() != productOfAttribs { + response.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( + request.Path, + av.Description(ctx), + fmt.Sprintf("%d", request.ConfigValue.ValueInt32()), + )) + } +} + +// EqualToProductOf returns an AttributeValidator which ensures that any configured +// attribute value: +// +// - Is a number, which can be represented by a 32-bit integer. +// - Is equal to the product of the given attributes retrieved via the given path expression(s). +// +// Validation is skipped if any null (unconfigured) and/or unknown (known after apply) values are present. +func EqualToProductOf(attributesToMultiplyPathExpressions ...path.Expression) validator.Int32 { + return equalToProductOfValidator{attributesToMultiplyPathExpressions} +} diff --git a/int32validator/equal_to_product_of_example_test.go b/int32validator/equal_to_product_of_example_test.go new file mode 100644 index 0000000..93ad7a6 --- /dev/null +++ b/int32validator/equal_to_product_of_example_test.go @@ -0,0 +1,37 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32validator_test + +import ( + "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-validators/int32validator" +) + +func ExampleEqualToProductOf() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Int32Attribute{ + Required: true, + Validators: []validator.Int32{ + // Validate this integer value must be equal to the + // product of integer values other_attr1 and other_attr2. + int32validator.EqualToProductOf(path.Expressions{ + path.MatchRoot("other_attr1"), + path.MatchRoot("other_attr2"), + }...), + }, + }, + "other_attr1": schema.Int32Attribute{ + Required: true, + }, + "other_attr2": schema.Int32Attribute{ + Required: true, + }, + }, + } +} diff --git a/int32validator/equal_to_product_of_test.go b/int32validator/equal_to_product_of_test.go new file mode 100644 index 0000000..5d6e060 --- /dev/null +++ b/int32validator/equal_to_product_of_test.go @@ -0,0 +1,182 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32validator + +import ( + "context" + "testing" + + "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" +) + +func TestEqualToProductOfValidator(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.Int32 + attributesToMultiplyPathExpressions path.Expressions + requestConfigRaw map[string]tftypes.Value + expectError bool + } + tests := map[string]testCase{ + "unknown Int32": { + val: types.Int32Unknown(), + }, + "null Int32": { + val: types.Int32Null(), + }, + "valid integer as Int32 more than product of attributes": { + val: types.Int32Value(26), + attributesToMultiplyPathExpressions: path.Expressions{ + path.MatchRoot("one"), + path.MatchRoot("two"), + }, + requestConfigRaw: map[string]tftypes.Value{ + "one": tftypes.NewValue(tftypes.Number, 5), + "two": tftypes.NewValue(tftypes.Number, 5), + }, + expectError: true, + }, + "valid integer as Int32 less than product of attributes": { + val: types.Int32Value(24), + attributesToMultiplyPathExpressions: path.Expressions{ + path.MatchRoot("one"), + path.MatchRoot("two"), + }, + requestConfigRaw: map[string]tftypes.Value{ + "one": tftypes.NewValue(tftypes.Number, 5), + "two": tftypes.NewValue(tftypes.Number, 5), + }, + expectError: true, + }, + "valid integer as Int32 equal to product of attributes": { + val: types.Int32Value(25), + attributesToMultiplyPathExpressions: path.Expressions{ + path.MatchRoot("one"), + path.MatchRoot("two"), + }, + requestConfigRaw: map[string]tftypes.Value{ + "one": tftypes.NewValue(tftypes.Number, 5), + "two": tftypes.NewValue(tftypes.Number, 5), + }, + }, + "validation skipped when one attribute is null": { + val: types.Int32Value(10), + attributesToMultiplyPathExpressions: path.Expressions{ + path.MatchRoot("one"), + path.MatchRoot("two"), + }, + requestConfigRaw: map[string]tftypes.Value{ + "one": tftypes.NewValue(tftypes.Number, nil), + "two": tftypes.NewValue(tftypes.Number, 8), + }, + }, + "validation skipped when all attributes are null": { + val: types.Int32Null(), + attributesToMultiplyPathExpressions: path.Expressions{ + path.MatchRoot("one"), + path.MatchRoot("two"), + }, + requestConfigRaw: map[string]tftypes.Value{ + "one": tftypes.NewValue(tftypes.Number, nil), + "two": tftypes.NewValue(tftypes.Number, nil), + }, + }, + "validation skipped when all attributes to multiply are null": { + val: types.Int32Value(1), + attributesToMultiplyPathExpressions: path.Expressions{ + path.MatchRoot("one"), + path.MatchRoot("two"), + }, + requestConfigRaw: map[string]tftypes.Value{ + "one": tftypes.NewValue(tftypes.Number, nil), + "two": tftypes.NewValue(tftypes.Number, nil), + }, + }, + "validation skipped when one attribute is unknown": { + val: types.Int32Value(10), + attributesToMultiplyPathExpressions: path.Expressions{ + path.MatchRoot("one"), + path.MatchRoot("two"), + }, + requestConfigRaw: map[string]tftypes.Value{ + "one": tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), + "two": tftypes.NewValue(tftypes.Number, 8), + }, + }, + "validation skipped when all attributes are unknown": { + val: types.Int32Unknown(), + attributesToMultiplyPathExpressions: path.Expressions{ + path.MatchRoot("one"), + path.MatchRoot("two"), + }, + requestConfigRaw: map[string]tftypes.Value{ + "one": tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), + "two": tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), + }, + }, + "validation skipped when all attributes to multiply are unknown": { + val: types.Int32Value(1), + attributesToMultiplyPathExpressions: path.Expressions{ + path.MatchRoot("one"), + path.MatchRoot("two"), + }, + requestConfigRaw: map[string]tftypes.Value{ + "one": tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), + "two": tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), + }, + }, + "error when attribute to multiply is not Number": { + val: types.Int32Value(9), + attributesToMultiplyPathExpressions: path.Expressions{ + path.MatchRoot("one"), + path.MatchRoot("two"), + }, + requestConfigRaw: map[string]tftypes.Value{ + "one": tftypes.NewValue(tftypes.Bool, true), + "two": tftypes.NewValue(tftypes.Number, 9), + }, + expectError: true, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + request := validator.Int32Request{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + Config: tfsdk.Config{ + Raw: tftypes.NewValue(tftypes.Object{}, test.requestConfigRaw), + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.Int32Attribute{}, + "one": schema.Int32Attribute{}, + "two": schema.Int32Attribute{}, + }, + }, + }, + } + + response := validator.Int32Response{} + + EqualToProductOf(test.attributesToMultiplyPathExpressions...).ValidateInt32(context.Background(), 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/int32validator/equal_to_sum_of.go b/int32validator/equal_to_sum_of.go new file mode 100644 index 0000000..e7163a0 --- /dev/null +++ b/int32validator/equal_to_sum_of.go @@ -0,0 +1,116 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32validator + +import ( + "context" + "fmt" + "strings" + + "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 _ validator.Int32 = equalToSumOfValidator{} + +// equalToSumOfValidator validates that an integer Attribute's value equals the sum of one +// or more integer Attributes retrieved via the given path expressions. +type equalToSumOfValidator struct { + attributesToSumPathExpressions path.Expressions +} + +// Description describes the validation in plain text formatting. +func (av equalToSumOfValidator) Description(_ context.Context) string { + var attributePaths []string + for _, p := range av.attributesToSumPathExpressions { + attributePaths = append(attributePaths, p.String()) + } + + return fmt.Sprintf("value must be equal to the sum of %s", strings.Join(attributePaths, " + ")) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (av equalToSumOfValidator) MarkdownDescription(ctx context.Context) string { + return av.Description(ctx) +} + +// ValidateInt32 performs the validation. +func (av equalToSumOfValidator) ValidateInt32(ctx context.Context, request validator.Int32Request, response *validator.Int32Response) { + if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() { + return + } + + // Ensure input path expressions resolution against the current attribute + expressions := request.PathExpression.MergeExpressions(av.attributesToSumPathExpressions...) + + // Sum the value of all the attributes involved, but only if they are all known. + var sumOfAttribs int32 + for _, expression := range expressions { + matchedPaths, diags := request.Config.PathMatches(ctx, expression) + response.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(request.Path) { + continue + } + + // Get the value + var matchedValue attr.Value + diags := request.Config.GetAttribute(ctx, mp, &matchedValue) + response.Diagnostics.Append(diags...) + if diags.HasError() { + continue + } + + if matchedValue.IsUnknown() { + return + } + + if matchedValue.IsNull() { + continue + } + + // We know there is a value, convert it to the expected type + var attribToSum types.Int32 + diags = tfsdk.ValueAs(ctx, matchedValue, &attribToSum) + response.Diagnostics.Append(diags...) + if diags.HasError() { + continue + } + + sumOfAttribs += attribToSum.ValueInt32() + } + } + + if request.ConfigValue.ValueInt32() != sumOfAttribs { + response.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( + request.Path, + av.Description(ctx), + fmt.Sprintf("%d", request.ConfigValue.ValueInt32()), + )) + } +} + +// EqualToSumOf returns an AttributeValidator which ensures that any configured +// attribute value: +// +// - Is a number, which can be represented by a 32-bit integer. +// - 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) validator.Int32 { + return equalToSumOfValidator{attributesToSumPathExpressions} +} diff --git a/int32validator/equal_to_sum_of_example_test.go b/int32validator/equal_to_sum_of_example_test.go new file mode 100644 index 0000000..8abe66c --- /dev/null +++ b/int32validator/equal_to_sum_of_example_test.go @@ -0,0 +1,37 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32validator_test + +import ( + "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-validators/int32validator" +) + +func ExampleEqualToSumOf() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Int32Attribute{ + Required: true, + Validators: []validator.Int32{ + // Validate this integer value must be equal to the + // summed integer values of other_attr1 and other_attr2. + int32validator.EqualToSumOf(path.Expressions{ + path.MatchRoot("other_attr1"), + path.MatchRoot("other_attr2"), + }...), + }, + }, + "other_attr1": schema.Int32Attribute{ + Required: true, + }, + "other_attr2": schema.Int32Attribute{ + Required: true, + }, + }, + } +} diff --git a/int32validator/equal_to_sum_of_test.go b/int32validator/equal_to_sum_of_test.go new file mode 100644 index 0000000..76a122c --- /dev/null +++ b/int32validator/equal_to_sum_of_test.go @@ -0,0 +1,183 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32validator + +import ( + "context" + "testing" + + "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" +) + +func TestEqualToSumOfValidator(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.Int32 + attributesToSumPathExpressions path.Expressions + requestConfigRaw map[string]tftypes.Value + expectError bool + } + tests := map[string]testCase{ + "unknown Int32": { + val: types.Int32Unknown(), + }, + "null Int32": { + val: types.Int32Null(), + }, + "valid integer as Int32 more than sum of attributes": { + val: types.Int32Value(11), + attributesToSumPathExpressions: path.Expressions{ + path.MatchRoot("one"), + path.MatchRoot("two"), + }, + requestConfigRaw: map[string]tftypes.Value{ + "one": tftypes.NewValue(tftypes.Number, 5), + "two": tftypes.NewValue(tftypes.Number, 5), + }, + expectError: true, + }, + "valid integer as Int32 less than sum of attributes": { + val: types.Int32Value(9), + attributesToSumPathExpressions: path.Expressions{ + path.MatchRoot("one"), + path.MatchRoot("two"), + }, + requestConfigRaw: map[string]tftypes.Value{ + "one": tftypes.NewValue(tftypes.Number, 5), + "two": tftypes.NewValue(tftypes.Number, 5), + }, + expectError: true, + }, + "valid integer as Int32 equal to sum of attributes": { + val: types.Int32Value(10), + attributesToSumPathExpressions: path.Expressions{ + path.MatchRoot("one"), + path.MatchRoot("two"), + }, + requestConfigRaw: map[string]tftypes.Value{ + "one": tftypes.NewValue(tftypes.Number, 5), + "two": tftypes.NewValue(tftypes.Number, 5), + }, + }, + "valid integer as Int32 equal to sum of attributes, when one summed attribute is null": { + val: types.Int32Value(8), + attributesToSumPathExpressions: path.Expressions{ + path.MatchRoot("one"), + path.MatchRoot("two"), + }, + requestConfigRaw: map[string]tftypes.Value{ + "one": tftypes.NewValue(tftypes.Number, nil), + "two": tftypes.NewValue(tftypes.Number, 8), + }, + }, + "valid integer as Int32 does not return error when all attributes are null": { + val: types.Int32Null(), + attributesToSumPathExpressions: path.Expressions{ + path.MatchRoot("one"), + path.MatchRoot("two"), + }, + requestConfigRaw: map[string]tftypes.Value{ + "one": tftypes.NewValue(tftypes.Number, nil), + "two": tftypes.NewValue(tftypes.Number, nil), + }, + }, + "valid integer as Int32 returns error when all attributes to sum are null": { + val: types.Int32Value(1), + attributesToSumPathExpressions: path.Expressions{ + path.MatchRoot("one"), + path.MatchRoot("two"), + }, + requestConfigRaw: map[string]tftypes.Value{ + "one": tftypes.NewValue(tftypes.Number, nil), + "two": tftypes.NewValue(tftypes.Number, nil), + }, + expectError: true, + }, + "valid integer as Int32 equal to sum of attributes, when one summed attribute is unknown": { + val: types.Int32Value(8), + attributesToSumPathExpressions: path.Expressions{ + path.MatchRoot("one"), + path.MatchRoot("two"), + }, + requestConfigRaw: map[string]tftypes.Value{ + "one": tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), + "two": tftypes.NewValue(tftypes.Number, 8), + }, + }, + "valid integer as Int32 does not return error when all attributes are unknown": { + val: types.Int32Unknown(), + attributesToSumPathExpressions: path.Expressions{ + path.MatchRoot("one"), + path.MatchRoot("two"), + }, + requestConfigRaw: map[string]tftypes.Value{ + "one": tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), + "two": tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), + }, + }, + "valid integer as Int32 does not return error when all attributes to sum are unknown": { + val: types.Int32Value(1), + attributesToSumPathExpressions: path.Expressions{ + path.MatchRoot("one"), + path.MatchRoot("two"), + }, + requestConfigRaw: map[string]tftypes.Value{ + "one": tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), + "two": tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), + }, + }, + "error when attribute to sum is not Number": { + val: types.Int32Value(9), + attributesToSumPathExpressions: path.Expressions{ + path.MatchRoot("one"), + path.MatchRoot("two"), + }, + requestConfigRaw: map[string]tftypes.Value{ + "one": tftypes.NewValue(tftypes.Bool, true), + "two": tftypes.NewValue(tftypes.Number, 9), + }, + expectError: true, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + request := validator.Int32Request{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + Config: tfsdk.Config{ + Raw: tftypes.NewValue(tftypes.Object{}, test.requestConfigRaw), + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.Int32Attribute{}, + "one": schema.Int32Attribute{}, + "two": schema.Int32Attribute{}, + }, + }, + }, + } + + response := validator.Int32Response{} + + EqualToSumOf(test.attributesToSumPathExpressions...).ValidateInt32(context.Background(), 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/int32validator/exactly_one_of.go b/int32validator/exactly_one_of.go new file mode 100644 index 0000000..4bdacfa --- /dev/null +++ b/int32validator/exactly_one_of.go @@ -0,0 +1,29 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32validator + +import ( + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" +) + +// 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.Int32 { + return schemavalidator.ExactlyOneOfValidator{ + PathExpressions: expressions, + } +} diff --git a/int32validator/exactly_one_of_example_test.go b/int32validator/exactly_one_of_example_test.go new file mode 100644 index 0000000..d9c7c4a --- /dev/null +++ b/int32validator/exactly_one_of_example_test.go @@ -0,0 +1,32 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32validator_test + +import ( + "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-validators/int32validator" +) + +func ExampleExactlyOneOf() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Int32Attribute{ + Optional: true, + Validators: []validator.Int32{ + // Validate only this attribute or other_attr is configured. + int32validator.ExactlyOneOf(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.StringAttribute{ + Optional: true, + }, + }, + } +} diff --git a/int32validator/none_of.go b/int32validator/none_of.go new file mode 100644 index 0000000..c22bba9 --- /dev/null +++ b/int32validator/none_of.go @@ -0,0 +1,65 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32validator + +import ( + "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" +) + +var _ validator.Int32 = noneOfValidator{} + +// noneOfValidator validates that the value does not match one of the values. +type noneOfValidator struct { + values []types.Int32 +} + +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) ValidateInt32(ctx context.Context, request validator.Int32Request, response *validator.Int32Response) { + 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 Int32 held in the attribute +// is none of the given `values`. +func NoneOf(values ...int32) validator.Int32 { + frameworkValues := make([]types.Int32, 0, len(values)) + + for _, value := range values { + frameworkValues = append(frameworkValues, types.Int32Value(value)) + } + + return noneOfValidator{ + values: frameworkValues, + } +} diff --git a/int32validator/none_of_example_test.go b/int32validator/none_of_example_test.go new file mode 100644 index 0000000..4e7f232 --- /dev/null +++ b/int32validator/none_of_example_test.go @@ -0,0 +1,26 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32validator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + + "github.com/hashicorp/terraform-plugin-framework-validators/int32validator" +) + +func ExampleNoneOf() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Int32Attribute{ + Required: true, + Validators: []validator.Int32{ + // Validate integer value must not be 12, 24, or 48 + int32validator.NoneOf([]int32{12, 24, 48}...), + }, + }, + }, + } +} diff --git a/int32validator/none_of_test.go b/int32validator/none_of_test.go new file mode 100644 index 0000000..d2b3769 --- /dev/null +++ b/int32validator/none_of_test.go @@ -0,0 +1,88 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32validator_test + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/int32validator" +) + +func TestNoneOfValidator(t *testing.T) { + t.Parallel() + + type testCase struct { + in types.Int32 + validator validator.Int32 + expErrors int + } + + testCases := map[string]testCase{ + "simple-match": { + in: types.Int32Value(123), + validator: int32validator.NoneOf( + 123, + 234, + 8910, + 1213, + ), + expErrors: 1, + }, + "simple-mismatch": { + in: types.Int32Value(123), + validator: int32validator.NoneOf( + 234, + 8910, + 1213, + ), + expErrors: 0, + }, + "skip-validation-on-null": { + in: types.Int32Null(), + validator: int32validator.NoneOf( + 234, + 8910, + 1213, + ), + expErrors: 0, + }, + "skip-validation-on-unknown": { + in: types.Int32Unknown(), + validator: int32validator.NoneOf( + 234, + 8910, + 1213, + ), + expErrors: 0, + }, + } + + for name, test := range testCases { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + req := validator.Int32Request{ + ConfigValue: test.in, + } + res := validator.Int32Response{} + test.validator.ValidateInt32(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/int32validator/one_of.go b/int32validator/one_of.go new file mode 100644 index 0000000..04974b5 --- /dev/null +++ b/int32validator/one_of.go @@ -0,0 +1,63 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32validator + +import ( + "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" +) + +var _ validator.Int32 = oneOfValidator{} + +// oneOfValidator validates that the value matches one of expected values. +type oneOfValidator struct { + values []types.Int32 +} + +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) ValidateInt32(ctx context.Context, request validator.Int32Request, response *validator.Int32Response) { + 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 Int32 held in the attribute +// is one of the given `values`. +func OneOf(values ...int32) validator.Int32 { + frameworkValues := make([]types.Int32, 0, len(values)) + + for _, value := range values { + frameworkValues = append(frameworkValues, types.Int32Value(value)) + } + + return oneOfValidator{ + values: frameworkValues, + } +} diff --git a/int32validator/one_of_example_test.go b/int32validator/one_of_example_test.go new file mode 100644 index 0000000..90fa475 --- /dev/null +++ b/int32validator/one_of_example_test.go @@ -0,0 +1,26 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32validator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + + "github.com/hashicorp/terraform-plugin-framework-validators/int32validator" +) + +func ExampleOneOf() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.Int32Attribute{ + Required: true, + Validators: []validator.Int32{ + // Validate integer value must be 12, 24, or 48 + int32validator.OneOf([]int32{12, 24, 48}...), + }, + }, + }, + } +} diff --git a/int32validator/one_of_test.go b/int32validator/one_of_test.go new file mode 100644 index 0000000..e808661 --- /dev/null +++ b/int32validator/one_of_test.go @@ -0,0 +1,88 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32validator_test + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/int32validator" +) + +func TestOneOfValidator(t *testing.T) { + t.Parallel() + + type testCase struct { + in types.Int32 + validator validator.Int32 + expErrors int + } + + testCases := map[string]testCase{ + "simple-match": { + in: types.Int32Value(123), + validator: int32validator.OneOf( + 123, + 234, + 8910, + 1213, + ), + expErrors: 0, + }, + "simple-mismatch": { + in: types.Int32Value(123), + validator: int32validator.OneOf( + 234, + 8910, + 1213, + ), + expErrors: 1, + }, + "skip-validation-on-null": { + in: types.Int32Null(), + validator: int32validator.OneOf( + 234, + 8910, + 1213, + ), + expErrors: 0, + }, + "skip-validation-on-unknown": { + in: types.Int32Unknown(), + validator: int32validator.OneOf( + 234, + 8910, + 1213, + ), + expErrors: 0, + }, + } + + for name, test := range testCases { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + req := validator.Int32Request{ + ConfigValue: test.in, + } + res := validator.Int32Response{} + test.validator.ValidateInt32(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/int64validator/all_example_test.go b/int64validator/all_example_test.go index 8bc7dfb..6deb99e 100644 --- a/int64validator/all_example_test.go +++ b/int64validator/all_example_test.go @@ -4,9 +4,10 @@ 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" + + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" ) func ExampleAll() { @@ -17,13 +18,13 @@ func ExampleAll() { Required: true, Validators: []validator.Int64{ // Validate this Int64 value must either be: - // - 1.0 - // - At least 2.0, but not 3.0 + // - 1 + // - At least 2, but not 3 int64validator.Any( - int64validator.OneOf(1.0), + int64validator.OneOf(1), int64validator.All( - int64validator.AtLeast(2.0), - int64validator.NoneOf(3.0), + int64validator.AtLeast(2), + int64validator.NoneOf(3), ), ), }, diff --git a/int64validator/any_example_test.go b/int64validator/any_example_test.go index a9572cf..d19a43b 100644 --- a/int64validator/any_example_test.go +++ b/int64validator/any_example_test.go @@ -4,9 +4,10 @@ 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" + + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" ) func ExampleAny() { @@ -17,11 +18,11 @@ func ExampleAny() { Required: true, Validators: []validator.Int64{ // Validate this Int64 value must either be: - // - 1.0 - // - At least 2.0 + // - 1 + // - At least 2 int64validator.Any( - int64validator.OneOf(1.0), - int64validator.AtLeast(2.0), + int64validator.OneOf(1), + int64validator.AtLeast(2), ), }, }, diff --git a/int64validator/any_with_all_warnings_example_test.go b/int64validator/any_with_all_warnings_example_test.go index fe1645d..0be12d4 100644 --- a/int64validator/any_with_all_warnings_example_test.go +++ b/int64validator/any_with_all_warnings_example_test.go @@ -4,9 +4,10 @@ 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" + + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" ) func ExampleAnyWithAllWarnings() { @@ -17,11 +18,11 @@ func ExampleAnyWithAllWarnings() { Required: true, Validators: []validator.Int64{ // Validate this Int64 value must either be: - // - 1.0 - // - At least 2.0 + // - 1 + // - At least 2 int64validator.AnyWithAllWarnings( - int64validator.OneOf(1.0), - int64validator.AtLeast(2.0), + int64validator.OneOf(1), + int64validator.AtLeast(2), ), }, }, diff --git a/internal/schemavalidator/also_requires.go b/internal/schemavalidator/also_requires.go index 2d4c38a..7abc8ae 100644 --- a/internal/schemavalidator/also_requires.go +++ b/internal/schemavalidator/also_requires.go @@ -7,18 +7,21 @@ 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" + + "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" ) // This type of validator must satisfy all types. var ( _ validator.Bool = AlsoRequiresValidator{} + _ validator.Float32 = AlsoRequiresValidator{} _ validator.Float64 = AlsoRequiresValidator{} + _ validator.Int32 = AlsoRequiresValidator{} _ validator.Int64 = AlsoRequiresValidator{} _ validator.List = AlsoRequiresValidator{} _ validator.Map = AlsoRequiresValidator{} @@ -115,6 +118,20 @@ func (av AlsoRequiresValidator) ValidateBool(ctx context.Context, req validator. resp.Diagnostics.Append(validateResp.Diagnostics...) } +func (av AlsoRequiresValidator) ValidateFloat32(ctx context.Context, req validator.Float32Request, resp *validator.Float32Response) { + 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, @@ -129,6 +146,20 @@ func (av AlsoRequiresValidator) ValidateFloat64(ctx context.Context, req validat resp.Diagnostics.Append(validateResp.Diagnostics...) } +func (av AlsoRequiresValidator) ValidateInt32(ctx context.Context, req validator.Int32Request, resp *validator.Int32Response) { + 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, diff --git a/internal/schemavalidator/at_least_one_of.go b/internal/schemavalidator/at_least_one_of.go index 1c5daec..a31e450 100644 --- a/internal/schemavalidator/at_least_one_of.go +++ b/internal/schemavalidator/at_least_one_of.go @@ -7,18 +7,21 @@ 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" + + "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" ) // This type of validator must satisfy all types. var ( _ validator.Bool = AtLeastOneOfValidator{} + _ validator.Float32 = AtLeastOneOfValidator{} _ validator.Float64 = AtLeastOneOfValidator{} + _ validator.Int32 = AtLeastOneOfValidator{} _ validator.Int64 = AtLeastOneOfValidator{} _ validator.List = AtLeastOneOfValidator{} _ validator.Map = AtLeastOneOfValidator{} @@ -115,6 +118,20 @@ func (av AtLeastOneOfValidator) ValidateBool(ctx context.Context, req validator. resp.Diagnostics.Append(validateResp.Diagnostics...) } +func (av AtLeastOneOfValidator) ValidateFloat32(ctx context.Context, req validator.Float32Request, resp *validator.Float32Response) { + 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, @@ -129,6 +146,20 @@ func (av AtLeastOneOfValidator) ValidateFloat64(ctx context.Context, req validat resp.Diagnostics.Append(validateResp.Diagnostics...) } +func (av AtLeastOneOfValidator) ValidateInt32(ctx context.Context, req validator.Int32Request, resp *validator.Int32Response) { + 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, diff --git a/internal/schemavalidator/conflicts_with.go b/internal/schemavalidator/conflicts_with.go index b554953..185d58e 100644 --- a/internal/schemavalidator/conflicts_with.go +++ b/internal/schemavalidator/conflicts_with.go @@ -7,18 +7,21 @@ 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" + + "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" ) // This type of validator must satisfy all types. var ( _ validator.Bool = ConflictsWithValidator{} _ validator.Float64 = ConflictsWithValidator{} + _ validator.Float32 = ConflictsWithValidator{} + _ validator.Int32 = ConflictsWithValidator{} _ validator.Int64 = ConflictsWithValidator{} _ validator.List = ConflictsWithValidator{} _ validator.Map = ConflictsWithValidator{} @@ -115,6 +118,20 @@ func (av ConflictsWithValidator) ValidateBool(ctx context.Context, req validator resp.Diagnostics.Append(validateResp.Diagnostics...) } +func (av ConflictsWithValidator) ValidateFloat32(ctx context.Context, req validator.Float32Request, resp *validator.Float32Response) { + 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, @@ -129,6 +146,20 @@ func (av ConflictsWithValidator) ValidateFloat64(ctx context.Context, req valida resp.Diagnostics.Append(validateResp.Diagnostics...) } +func (av ConflictsWithValidator) ValidateInt32(ctx context.Context, req validator.Int32Request, resp *validator.Int32Response) { + 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, diff --git a/internal/schemavalidator/exactly_one_of.go b/internal/schemavalidator/exactly_one_of.go index f284fe2..40af438 100644 --- a/internal/schemavalidator/exactly_one_of.go +++ b/internal/schemavalidator/exactly_one_of.go @@ -7,18 +7,21 @@ 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" + + "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" ) // This type of validator must satisfy all types. var ( _ validator.Bool = ExactlyOneOfValidator{} + _ validator.Float32 = ExactlyOneOfValidator{} _ validator.Float64 = ExactlyOneOfValidator{} + _ validator.Int32 = ExactlyOneOfValidator{} _ validator.Int64 = ExactlyOneOfValidator{} _ validator.List = ExactlyOneOfValidator{} _ validator.Map = ExactlyOneOfValidator{} @@ -135,6 +138,20 @@ func (av ExactlyOneOfValidator) ValidateBool(ctx context.Context, req validator. resp.Diagnostics.Append(validateResp.Diagnostics...) } +func (av ExactlyOneOfValidator) ValidateFloat32(ctx context.Context, req validator.Float32Request, resp *validator.Float32Response) { + 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, @@ -149,6 +166,20 @@ func (av ExactlyOneOfValidator) ValidateFloat64(ctx context.Context, req validat resp.Diagnostics.Append(validateResp.Diagnostics...) } +func (av ExactlyOneOfValidator) ValidateInt32(ctx context.Context, req validator.Int32Request, resp *validator.Int32Response) { + 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, diff --git a/internal/testvalidator/warning.go b/internal/testvalidator/warning.go index cb9376d..f92a14a 100644 --- a/internal/testvalidator/warning.go +++ b/internal/testvalidator/warning.go @@ -28,6 +28,14 @@ func WarningDataSource(summary string, detail string) datasource.ConfigValidator } } +// WarningFloat32 returns a validator which returns a warning diagnostic. +func WarningFloat32(summary string, detail string) validator.Float32 { + 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{ @@ -36,6 +44,14 @@ func WarningFloat64(summary string, detail string) validator.Float64 { } } +// WarningInt64 returns a validator which returns a warning diagnostic. +func WarningInt32(summary string, detail string) validator.Int32 { + 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{ @@ -113,7 +129,9 @@ var ( _ provider.ConfigValidator = WarningValidator{} _ resource.ConfigValidator = WarningValidator{} _ validator.Bool = WarningValidator{} + _ validator.Float32 = WarningValidator{} _ validator.Float64 = WarningValidator{} + _ validator.Int32 = WarningValidator{} _ validator.Int64 = WarningValidator{} _ validator.List = WarningValidator{} _ validator.Map = WarningValidator{} @@ -144,10 +162,18 @@ func (v WarningValidator) ValidateDataSource(ctx context.Context, request dataso response.Diagnostics.AddWarning(v.Summary, v.Detail) } +func (v WarningValidator) ValidateFloat32(ctx context.Context, request validator.Float32Request, response *validator.Float32Response) { + 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) ValidateInt32(ctx context.Context, request validator.Int32Request, response *validator.Int32Response) { + 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) } diff --git a/listvalidator/value_float32s_are.go b/listvalidator/value_float32s_are.go new file mode 100644 index 0000000..58f8127 --- /dev/null +++ b/listvalidator/value_float32s_are.go @@ -0,0 +1,119 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package listvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +// ValueFloat32sAre returns an validator which ensures that any configured +// Float32 values passes each Float32 validator. +// +// Null (unconfigured) and unknown (known after apply) values are skipped. +func ValueFloat32sAre(elementValidators ...validator.Float32) validator.List { + return valueFloat32sAreValidator{ + elementValidators: elementValidators, + } +} + +var _ validator.List = valueFloat32sAreValidator{} + +// valueFloat32sAreValidator validates that each Float32 member validates against each of the value validators. +type valueFloat32sAreValidator struct { + elementValidators []validator.Float32 +} + +// Description describes the validation in plain text formatting. +func (v valueFloat32sAreValidator) 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 valueFloat32sAreValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +// ValidateFloat32 performs the validation. +func (v valueFloat32sAreValidator) ValidateList(ctx context.Context, req validator.ListRequest, resp *validator.ListResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + _, ok := req.ConfigValue.ElementType(ctx).(basetypes.Float32Typable) + + 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 Float32 values validator, however its values do not implement types.Float32Type or the types.Float32Typable interface for custom Float32 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.(basetypes.Float32Valuable) + + // 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 Float32 values validator, however its values do not implement types.Float32Type or the types.Float32Typable interface for custom Float32 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.ToFloat32Value(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.Float32Request{ + Path: elementPath, + PathExpression: elementPath.Expression(), + ConfigValue: elementValue, + Config: req.Config, + } + + for _, elementValidator := range v.elementValidators { + elementResp := &validator.Float32Response{} + + elementValidator.ValidateFloat32(ctx, elementReq, elementResp) + + resp.Diagnostics.Append(elementResp.Diagnostics...) + } + } +} diff --git a/listvalidator/value_float32s_are_example_test.go b/listvalidator/value_float32s_are_example_test.go new file mode 100644 index 0000000..5adc1f2 --- /dev/null +++ b/listvalidator/value_float32s_are_example_test.go @@ -0,0 +1,29 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package listvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/float32validator" + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" +) + +func ExampleValueFloat32sAre() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.ListAttribute{ + ElementType: types.Float32Type, + Required: true, + Validators: []validator.List{ + // Validate this List must contain Float32 values which are at least 1.2. + listvalidator.ValueFloat32sAre(float32validator.AtLeast(1.2)), + }, + }, + }, + } +} diff --git a/listvalidator/value_float32s_are_test.go b/listvalidator/value_float32s_are_test.go new file mode 100644 index 0000000..c54049b --- /dev/null +++ b/listvalidator/value_float32s_are_test.go @@ -0,0 +1,146 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +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/float32validator" + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" +) + +func TestValueFloat32sAreValidatorValidateList(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + val types.List + elementValidators []validator.Float32 + expectedDiagnostics diag.Diagnostics + }{ + "no element validators": { + val: types.ListValueMust( + types.Float32Type, + []attr.Value{ + types.Float32Value(1), + types.Float32Value(2), + }, + ), + }, + "List unknown": { + val: types.ListUnknown( + types.Float32Type, + ), + elementValidators: []validator.Float32{ + float32validator.AtLeast(1), + }, + }, + "List null": { + val: types.ListNull( + types.Float32Type, + ), + elementValidators: []validator.Float32{ + float32validator.AtLeast(1), + }, + }, + "List elements invalid": { + val: types.ListValueMust( + types.Float32Type, + []attr.Value{ + types.Float32Value(1), + types.Float32Value(2), + }, + ), + elementValidators: []validator.Float32{ + float32validator.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.Float32Type, + []attr.Value{ + types.Float32Value(1), + types.Float32Value(2), + }, + ), + elementValidators: []validator.Float32{ + float32validator.AtLeast(3), + float32validator.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.Float32Type, + []attr.Value{ + types.Float32Value(1), + types.Float32Value(2), + }, + ), + elementValidators: []validator.Float32{ + float32validator.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.ValueFloat32sAre(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_int32s_are.go b/listvalidator/value_int32s_are.go new file mode 100644 index 0000000..d4c0962 --- /dev/null +++ b/listvalidator/value_int32s_are.go @@ -0,0 +1,119 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package listvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +// ValueInt32sAre returns an validator which ensures that any configured +// Int32 values passes each Int32 validator. +// +// Null (unconfigured) and unknown (known after apply) values are skipped. +func ValueInt32sAre(elementValidators ...validator.Int32) validator.List { + return valueInt32sAreValidator{ + elementValidators: elementValidators, + } +} + +var _ validator.List = valueInt32sAreValidator{} + +// valueInt32sAreValidator validates that each Int32 member validates against each of the value validators. +type valueInt32sAreValidator struct { + elementValidators []validator.Int32 +} + +// Description describes the validation in plain text formatting. +func (v valueInt32sAreValidator) 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 valueInt32sAreValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +// ValidateInt32 performs the validation. +func (v valueInt32sAreValidator) ValidateList(ctx context.Context, req validator.ListRequest, resp *validator.ListResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + _, ok := req.ConfigValue.ElementType(ctx).(basetypes.Int32Typable) + + 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 Int32 values validator, however its values do not implement types.Int32Type or the types.Int32Typable interface for custom Int32 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.(basetypes.Int32Valuable) + + // 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 Int32 values validator, however its values do not implement types.Int32Type or the types.Int32Typable interface for custom Int32 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.ToInt32Value(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.Int32Request{ + Path: elementPath, + PathExpression: elementPath.Expression(), + ConfigValue: elementValue, + Config: req.Config, + } + + for _, elementValidator := range v.elementValidators { + elementResp := &validator.Int32Response{} + + elementValidator.ValidateInt32(ctx, elementReq, elementResp) + + resp.Diagnostics.Append(elementResp.Diagnostics...) + } + } +} diff --git a/listvalidator/value_int32s_are_example_test.go b/listvalidator/value_int32s_are_example_test.go new file mode 100644 index 0000000..ebdc5fb --- /dev/null +++ b/listvalidator/value_int32s_are_example_test.go @@ -0,0 +1,29 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package listvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/int32validator" + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" +) + +func ExampleValueInt32sAre() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.ListAttribute{ + ElementType: types.Int32Type, + Required: true, + Validators: []validator.List{ + // Validate this List must contain Int32 values which are at least 1. + listvalidator.ValueInt32sAre(int32validator.AtLeast(1)), + }, + }, + }, + } +} diff --git a/listvalidator/value_int32s_are_test.go b/listvalidator/value_int32s_are_test.go new file mode 100644 index 0000000..4ea0070 --- /dev/null +++ b/listvalidator/value_int32s_are_test.go @@ -0,0 +1,141 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +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/int32validator" + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" +) + +func TestValueInt32sAreValidatorValidateList(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + val types.List + elementValidators []validator.Int32 + expectedDiagnostics diag.Diagnostics + }{ + "no element validators": { + val: types.ListValueMust( + types.Int32Type, + []attr.Value{ + types.Int32Value(1), + types.Int32Value(2), + }, + ), + }, + "List unknown": { + val: types.ListUnknown( + types.Int32Type, + ), + elementValidators: []validator.Int32{ + int32validator.AtLeast(1), + }, + }, + "List null": { + val: types.ListNull( + types.Int32Type, + ), + elementValidators: []validator.Int32{ + int32validator.AtLeast(1), + }, + }, + "List elements invalid": { + val: types.ListValueMust( + types.Int32Type, + []attr.Value{ + types.Int32Value(1), + types.Int32Value(2), + }, + ), + elementValidators: []validator.Int32{ + int32validator.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.Int32Type, + []attr.Value{ + types.Int32Value(1), + types.Int32Value(2), + }, + ), + elementValidators: []validator.Int32{ + int32validator.AtLeast(3), + int32validator.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.Int32Type, + []attr.Value{ + types.Int32Value(1), + types.Int32Value(2), + }, + ), + elementValidators: []validator.Int32{ + int32validator.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.ValueInt32sAre(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/mapvalidator/value_float32s_are.go b/mapvalidator/value_float32s_are.go new file mode 100644 index 0000000..11c83e0 --- /dev/null +++ b/mapvalidator/value_float32s_are.go @@ -0,0 +1,119 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package mapvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +// ValueFloat32sAre returns an validator which ensures that any configured +// Float32 values passes each Float32 validator. +// +// Null (unconfigured) and unknown (known after apply) values are skipped. +func ValueFloat32sAre(elementValidators ...validator.Float32) validator.Map { + return valueFloat32sAreValidator{ + elementValidators: elementValidators, + } +} + +var _ validator.Map = valueFloat32sAreValidator{} + +// valueFloat32sAreValidator validates that each Float32 member validates against each of the value validators. +type valueFloat32sAreValidator struct { + elementValidators []validator.Float32 +} + +// Description describes the validation in plain text formatting. +func (v valueFloat32sAreValidator) 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 valueFloat32sAreValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +// ValidateFloat32 performs the validation. +func (v valueFloat32sAreValidator) ValidateMap(ctx context.Context, req validator.MapRequest, resp *validator.MapResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + _, ok := req.ConfigValue.ElementType(ctx).(basetypes.Float32Typable) + + 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 Float32 values validator, however its values do not implement types.Float32Type or the types.Float32Typable interface for custom Float32 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.(basetypes.Float32Valuable) + + // 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 Float32 values validator, however its values do not implement types.Float32Type or the types.Float32Typable interface for custom Float32 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.ToFloat32Value(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.Float32Request{ + Path: elementPath, + PathExpression: elementPath.Expression(), + ConfigValue: elementValue, + Config: req.Config, + } + + for _, elementValidator := range v.elementValidators { + elementResp := &validator.Float32Response{} + + elementValidator.ValidateFloat32(ctx, elementReq, elementResp) + + resp.Diagnostics.Append(elementResp.Diagnostics...) + } + } +} diff --git a/mapvalidator/value_float32s_are_example_test.go b/mapvalidator/value_float32s_are_example_test.go new file mode 100644 index 0000000..d730a0d --- /dev/null +++ b/mapvalidator/value_float32s_are_example_test.go @@ -0,0 +1,29 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package mapvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/float32validator" + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" +) + +func ExampleValueFloat32sAre() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.MapAttribute{ + ElementType: types.Float32Type, + Required: true, + Validators: []validator.Map{ + // Validate this Map must contain Float32 values which are at least 1.2. + mapvalidator.ValueFloat32sAre(float32validator.AtLeast(1.2)), + }, + }, + }, + } +} diff --git a/mapvalidator/value_float32s_are_test.go b/mapvalidator/value_float32s_are_test.go new file mode 100644 index 0000000..45743f9 --- /dev/null +++ b/mapvalidator/value_float32s_are_test.go @@ -0,0 +1,131 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +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/float32validator" + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" +) + +func TestValueFloat32sAreValidatorValidateMap(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + val types.Map + elementValidators []validator.Float32 + expectedDiagnostics diag.Diagnostics + }{ + "no element validators": { + val: types.MapValueMust( + types.Float32Type, + map[string]attr.Value{ + "key1": types.Float32Value(1), + "key2": types.Float32Value(2), + }, + ), + }, + "Map unknown": { + val: types.MapUnknown( + types.Float32Type, + ), + elementValidators: []validator.Float32{ + float32validator.AtLeast(1), + }, + }, + "Map null": { + val: types.MapNull( + types.Float32Type, + ), + elementValidators: []validator.Float32{ + float32validator.AtLeast(1), + }, + }, + "Map elements invalid": { + val: types.MapValueMust( + types.Float32Type, + map[string]attr.Value{ + "key1": types.Float32Value(1), + // Map ordering is random in Go, avoid multiple keys + }, + ), + elementValidators: []validator.Float32{ + float32validator.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.Float32Type, + map[string]attr.Value{ + "key1": types.Float32Value(1), + // Map ordering is random in Go, avoid multiple keys + }, + ), + elementValidators: []validator.Float32{ + float32validator.AtLeast(3), + float32validator.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.Float32Type, + map[string]attr.Value{ + "key1": types.Float32Value(1), + "key2": types.Float32Value(2), + }, + ), + elementValidators: []validator.Float32{ + float32validator.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.ValueFloat32sAre(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_int32s_are.go b/mapvalidator/value_int32s_are.go new file mode 100644 index 0000000..0f600d8 --- /dev/null +++ b/mapvalidator/value_int32s_are.go @@ -0,0 +1,119 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package mapvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +// ValueInt32sAre returns an validator which ensures that any configured +// Int32 values passes each Int32 validator. +// +// Null (unconfigured) and unknown (known after apply) values are skipped. +func ValueInt32sAre(elementValidators ...validator.Int32) validator.Map { + return valueInt32sAreValidator{ + elementValidators: elementValidators, + } +} + +var _ validator.Map = valueInt32sAreValidator{} + +// valueInt32sAreValidator validates that each Int32 member validates against each of the value validators. +type valueInt32sAreValidator struct { + elementValidators []validator.Int32 +} + +// Description describes the validation in plain text formatting. +func (v valueInt32sAreValidator) 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 valueInt32sAreValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +// ValidateInt32 performs the validation. +func (v valueInt32sAreValidator) ValidateMap(ctx context.Context, req validator.MapRequest, resp *validator.MapResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + _, ok := req.ConfigValue.ElementType(ctx).(basetypes.Int32Typable) + + 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 Int32 values validator, however its values do not implement types.Int32Type or the types.Int32Typable interface for custom Int32 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.(basetypes.Int32Valuable) + + // 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 Int32 values validator, however its values do not implement types.Int32Type or the types.Int32Typable interface for custom Int32 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.ToInt32Value(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.Int32Request{ + Path: elementPath, + PathExpression: elementPath.Expression(), + ConfigValue: elementValue, + Config: req.Config, + } + + for _, elementValidator := range v.elementValidators { + elementResp := &validator.Int32Response{} + + elementValidator.ValidateInt32(ctx, elementReq, elementResp) + + resp.Diagnostics.Append(elementResp.Diagnostics...) + } + } +} diff --git a/mapvalidator/value_int32s_are_example_test.go b/mapvalidator/value_int32s_are_example_test.go new file mode 100644 index 0000000..797b11a --- /dev/null +++ b/mapvalidator/value_int32s_are_example_test.go @@ -0,0 +1,29 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package mapvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/int32validator" + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" +) + +func ExampleValueInt32sAre() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.MapAttribute{ + ElementType: types.Int32Type, + Required: true, + Validators: []validator.Map{ + // Validate this Map must contain Int32 values which are at least 1. + mapvalidator.ValueInt32sAre(int32validator.AtLeast(1)), + }, + }, + }, + } +} diff --git a/mapvalidator/value_int32s_are_test.go b/mapvalidator/value_int32s_are_test.go new file mode 100644 index 0000000..dabb90f --- /dev/null +++ b/mapvalidator/value_int32s_are_test.go @@ -0,0 +1,131 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +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/int32validator" + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" +) + +func TestValueInt32sAreValidatorValidateMap(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + val types.Map + elementValidators []validator.Int32 + expectedDiagnostics diag.Diagnostics + }{ + "no element validators": { + val: types.MapValueMust( + types.Int32Type, + map[string]attr.Value{ + "key1": types.Int32Value(1), + "key2": types.Int32Value(2), + }, + ), + }, + "Map unknown": { + val: types.MapUnknown( + types.Int32Type, + ), + elementValidators: []validator.Int32{ + int32validator.AtLeast(1), + }, + }, + "Map null": { + val: types.MapNull( + types.Int32Type, + ), + elementValidators: []validator.Int32{ + int32validator.AtLeast(1), + }, + }, + "Map elements invalid": { + val: types.MapValueMust( + types.Int32Type, + map[string]attr.Value{ + "key1": types.Int32Value(1), + // Map ordering is random in Go, avoid multiple keys + }, + ), + elementValidators: []validator.Int32{ + int32validator.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.Int32Type, + map[string]attr.Value{ + "key1": types.Int32Value(1), + // Map ordering is random in Go, avoid multiple keys + }, + ), + elementValidators: []validator.Int32{ + int32validator.AtLeast(3), + int32validator.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.Int32Type, + map[string]attr.Value{ + "key1": types.Int32Value(1), + "key2": types.Int32Value(2), + }, + ), + elementValidators: []validator.Int32{ + int32validator.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.ValueInt32sAre(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/setvalidator/value_float32s_are.go b/setvalidator/value_float32s_are.go new file mode 100644 index 0000000..6c17fce --- /dev/null +++ b/setvalidator/value_float32s_are.go @@ -0,0 +1,119 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package setvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +// ValueFloat32sAre returns an validator which ensures that any configured +// Float32 values passes each Float32 validator. +// +// Null (unconfigured) and unknown (known after apply) values are skipped. +func ValueFloat32sAre(elementValidators ...validator.Float32) validator.Set { + return valueFloat32sAreValidator{ + elementValidators: elementValidators, + } +} + +var _ validator.Set = valueFloat32sAreValidator{} + +// valueFloat32sAreValidator validates that each Float32 member validates against each of the value validators. +type valueFloat32sAreValidator struct { + elementValidators []validator.Float32 +} + +// Description describes the validation in plain text formatting. +func (v valueFloat32sAreValidator) 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 valueFloat32sAreValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +// ValidateFloat32 performs the validation. +func (v valueFloat32sAreValidator) ValidateSet(ctx context.Context, req validator.SetRequest, resp *validator.SetResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + _, ok := req.ConfigValue.ElementType(ctx).(basetypes.Float32Typable) + + 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 Float32 values validator, however its values do not implement types.Float32Type or the types.Float32Typable interface for custom Float32 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.(basetypes.Float32Valuable) + + // 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 Float32 values validator, however its values do not implement types.Float32Type or the types.Float32Typable interface for custom Float32 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.ToFloat32Value(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.Float32Request{ + Path: elementPath, + PathExpression: elementPath.Expression(), + ConfigValue: elementValue, + Config: req.Config, + } + + for _, elementValidator := range v.elementValidators { + elementResp := &validator.Float32Response{} + + elementValidator.ValidateFloat32(ctx, elementReq, elementResp) + + resp.Diagnostics.Append(elementResp.Diagnostics...) + } + } +} diff --git a/setvalidator/value_float32s_are_example_test.go b/setvalidator/value_float32s_are_example_test.go new file mode 100644 index 0000000..0afc1de --- /dev/null +++ b/setvalidator/value_float32s_are_example_test.go @@ -0,0 +1,29 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package setvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/float32validator" + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" +) + +func ExampleValueFloat32sAre() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.SetAttribute{ + ElementType: types.Float32Type, + Required: true, + Validators: []validator.Set{ + // Validate this Set must contain Float32 values which are at least 1.2. + setvalidator.ValueFloat32sAre(float32validator.AtLeast(1.2)), + }, + }, + }, + } +} diff --git a/setvalidator/value_float32s_are_test.go b/setvalidator/value_float32s_are_test.go new file mode 100644 index 0000000..51051f0 --- /dev/null +++ b/setvalidator/value_float32s_are_test.go @@ -0,0 +1,146 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +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/float32validator" + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" +) + +func TestValueFloat32sAreValidatorValidateSet(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + val types.Set + elementValidators []validator.Float32 + expectedDiagnostics diag.Diagnostics + }{ + "no element validators": { + val: types.SetValueMust( + types.Float32Type, + []attr.Value{ + types.Float32Value(1), + types.Float32Value(2), + }, + ), + }, + "Set unknown": { + val: types.SetUnknown( + types.Float32Type, + ), + elementValidators: []validator.Float32{ + float32validator.AtLeast(1), + }, + }, + "Set null": { + val: types.SetNull( + types.Float32Type, + ), + elementValidators: []validator.Float32{ + float32validator.AtLeast(1), + }, + }, + "Set elements invalid": { + val: types.SetValueMust( + types.Float32Type, + []attr.Value{ + types.Float32Value(1), + types.Float32Value(2), + }, + ), + elementValidators: []validator.Float32{ + float32validator.AtLeast(3), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.Float32Value(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.Float32Value(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.Float32Type, + []attr.Value{ + types.Float32Value(1), + types.Float32Value(2), + }, + ), + elementValidators: []validator.Float32{ + float32validator.AtLeast(3), + float32validator.AtLeast(4), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.Float32Value(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.Float32Value(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.Float32Value(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.Float32Value(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.Float32Type, + []attr.Value{ + types.Float32Value(1), + types.Float32Value(2), + }, + ), + elementValidators: []validator.Float32{ + float32validator.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.ValueFloat32sAre(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_int32s_are.go b/setvalidator/value_int32s_are.go new file mode 100644 index 0000000..16aaed1 --- /dev/null +++ b/setvalidator/value_int32s_are.go @@ -0,0 +1,119 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package setvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +// ValueInt32sAre returns an validator which ensures that any configured +// Int32 values passes each Int32 validator. +// +// Null (unconfigured) and unknown (known after apply) values are skipped. +func ValueInt32sAre(elementValidators ...validator.Int32) validator.Set { + return valueInt32sAreValidator{ + elementValidators: elementValidators, + } +} + +var _ validator.Set = valueInt32sAreValidator{} + +// valueInt32sAreValidator validates that each Int32 member validates against each of the value validators. +type valueInt32sAreValidator struct { + elementValidators []validator.Int32 +} + +// Description describes the validation in plain text formatting. +func (v valueInt32sAreValidator) 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 valueInt32sAreValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +// ValidateInt32 performs the validation. +func (v valueInt32sAreValidator) ValidateSet(ctx context.Context, req validator.SetRequest, resp *validator.SetResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + _, ok := req.ConfigValue.ElementType(ctx).(basetypes.Int32Typable) + + 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 Int32 values validator, however its values do not implement types.Int32Type or the types.Int32Typable interface for custom Int32 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.(basetypes.Int32Valuable) + + // 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 Int32 values validator, however its values do not implement types.Int32Type or the types.Int32Typable interface for custom Int32 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.ToInt32Value(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.Int32Request{ + Path: elementPath, + PathExpression: elementPath.Expression(), + ConfigValue: elementValue, + Config: req.Config, + } + + for _, elementValidator := range v.elementValidators { + elementResp := &validator.Int32Response{} + + elementValidator.ValidateInt32(ctx, elementReq, elementResp) + + resp.Diagnostics.Append(elementResp.Diagnostics...) + } + } +} diff --git a/setvalidator/value_int32s_are_example_test.go b/setvalidator/value_int32s_are_example_test.go new file mode 100644 index 0000000..6edd5cb --- /dev/null +++ b/setvalidator/value_int32s_are_example_test.go @@ -0,0 +1,29 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package setvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework-validators/int32validator" + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" +) + +func ExampleValueInt32sAre() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.SetAttribute{ + ElementType: types.Int32Type, + Required: true, + Validators: []validator.Set{ + // Validate this Set must contain Int32 values which are at least 1. + setvalidator.ValueInt32sAre(int32validator.AtLeast(1)), + }, + }, + }, + } +} diff --git a/setvalidator/value_int32s_are_test.go b/setvalidator/value_int32s_are_test.go new file mode 100644 index 0000000..487ef96 --- /dev/null +++ b/setvalidator/value_int32s_are_test.go @@ -0,0 +1,146 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +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/int32validator" + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" +) + +func TestValueInt32sAreValidatorValidateSet(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + val types.Set + elementValidators []validator.Int32 + expectedDiagnostics diag.Diagnostics + }{ + "no element validators": { + val: types.SetValueMust( + types.Int32Type, + []attr.Value{ + types.Int32Value(1), + types.Int32Value(2), + }, + ), + }, + "Set unknown": { + val: types.SetUnknown( + types.Int32Type, + ), + elementValidators: []validator.Int32{ + int32validator.AtLeast(1), + }, + }, + "Set null": { + val: types.SetNull( + types.Int32Type, + ), + elementValidators: []validator.Int32{ + int32validator.AtLeast(1), + }, + }, + "Set elements invalid": { + val: types.SetValueMust( + types.Int32Type, + []attr.Value{ + types.Int32Value(1), + types.Int32Value(2), + }, + ), + elementValidators: []validator.Int32{ + int32validator.AtLeast(3), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.Int32Value(1)), + "Invalid Attribute Value", + "Attribute test[Value(1)] value must be at least 3, got: 1", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.Int32Value(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.Int32Type, + []attr.Value{ + types.Int32Value(1), + types.Int32Value(2), + }, + ), + elementValidators: []validator.Int32{ + int32validator.AtLeast(3), + int32validator.AtLeast(4), + }, + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.Int32Value(1)), + "Invalid Attribute Value", + "Attribute test[Value(1)] value must be at least 3, got: 1", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.Int32Value(1)), + "Invalid Attribute Value", + "Attribute test[Value(1)] value must be at least 4, got: 1", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.Int32Value(2)), + "Invalid Attribute Value", + "Attribute test[Value(2)] value must be at least 3, got: 2", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.Int32Value(2)), + "Invalid Attribute Value", + "Attribute test[Value(2)] value must be at least 4, got: 2", + ), + }, + }, + "Set elements valid": { + val: types.SetValueMust( + types.Int32Type, + []attr.Value{ + types.Int32Value(1), + types.Int32Value(2), + }, + ), + elementValidators: []validator.Int32{ + int32validator.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.ValueInt32sAre(testCase.elementValidators...).ValidateSet(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, testCase.expectedDiagnostics); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +}