diff --git a/.changes/0.16.0.md b/.changes/0.16.0.md new file mode 100644 index 00000000..414f04e7 --- /dev/null +++ b/.changes/0.16.0.md @@ -0,0 +1,13 @@ +## 0.16.0 (December 12, 2024) + +FEATURES: + +* listvalidator: Added `NoNullValues` validator ([#245](https://github.com/hashicorp/terraform-plugin-framework-validators/issues/245)) +* mapvalidator: Added `NoNullValues` validator ([#245](https://github.com/hashicorp/terraform-plugin-framework-validators/issues/245)) +* setvalidator: Added `NoNullValues` validator ([#245](https://github.com/hashicorp/terraform-plugin-framework-validators/issues/245)) +* dynamicvalidator: New package which contains `types.Dynamic` specific validators ([#249](https://github.com/hashicorp/terraform-plugin-framework-validators/issues/249)) + +BUG FIXES: + +* Fixed bug with `ConflictsWith` and `AlsoRequires` validators where unknown values would raise invalid diagnostics during `terraform validate`. ([#251](https://github.com/hashicorp/terraform-plugin-framework-validators/issues/251)) + diff --git a/.github/workflows/ci-changie.yml b/.github/workflows/ci-changie.yml index 33ba8ee8..3031aad8 100644 --- a/.github/workflows/ci-changie.yml +++ b/.github/workflows/ci-changie.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: # Ensure terraform-devex-repos is updated on version changes. - - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 # Ensure terraform-devex-repos is updated on version changes. - uses: miniscruff/changie-action@6dcc2533cac0495148ed4046c438487e4dceaa23 # v2.0.0 with: diff --git a/.github/workflows/ci-github-actions.yml b/.github/workflows/ci-github-actions.yml index 47f1bbb6..9d7e22dc 100644 --- a/.github/workflows/ci-github-actions.yml +++ b/.github/workflows/ci-github-actions.yml @@ -13,8 +13,8 @@ jobs: actionlint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 - - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 with: go-version-file: 'go.mod' - run: go install github.com/rhysd/actionlint/cmd/actionlint@latest diff --git a/.github/workflows/ci-go.yml b/.github/workflows/ci-go.yml index 813d4dbb..7e2da688 100644 --- a/.github/workflows/ci-go.yml +++ b/.github/workflows/ci-go.yml @@ -16,8 +16,8 @@ jobs: golangci-lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 - - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 with: go-version-file: 'go.mod' - run: go mod download @@ -30,8 +30,8 @@ jobs: matrix: go-version: [ '1.23', '1.22' ] steps: - - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 - - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 with: go-version: ${{ matrix.go-version }} - run: go mod download diff --git a/.github/workflows/ci-goreleaser.yml b/.github/workflows/ci-goreleaser.yml index 9e5193bc..2c0ae1af 100644 --- a/.github/workflows/ci-goreleaser.yml +++ b/.github/workflows/ci-goreleaser.yml @@ -14,10 +14,10 @@ jobs: check: runs-on: ubuntu-latest steps: - - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 - - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 with: go-version-file: 'go.mod' - - uses: goreleaser/goreleaser-action@286f3b13b1b49da4ac219696163fb8c1c93e1200 # v6.0.0 + - uses: goreleaser/goreleaser-action@9ed2f89a662bf1735a48bc8557fd212fa902bebf # v6.1.0 with: args: check diff --git a/.github/workflows/compliance.yml b/.github/workflows/compliance.yml index 9511923d..6a82766b 100644 --- a/.github/workflows/compliance.yml +++ b/.github/workflows/compliance.yml @@ -11,7 +11,7 @@ jobs: copywrite: runs-on: ubuntu-latest steps: - - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: hashicorp/setup-copywrite@32638da2d4e81d56a0764aa1547882fc4d209636 # v1.1.3 - run: copywrite headers --plan - run: copywrite license --plan diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4b3613dc..4899981c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 # Avoid persisting GITHUB_TOKEN credentials as they take priority over our service account PAT for `git push` operations @@ -54,7 +54,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 # Default input is the SHA that initially triggered the workflow. As we created a new commit in the previous job, @@ -79,12 +79,12 @@ jobs: contents: write # Needed for goreleaser to create GitHub release issues: write # Needed for goreleaser to close associated milestone steps: - - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: ${{ inputs.versionNumber }} fetch-depth: 0 - - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 + - uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 with: go-version-file: 'go.mod' @@ -93,7 +93,7 @@ jobs: cd .changes sed -e "1{/# /d;}" -e "2{/^$/d;}" ${{ needs.changelog-version.outputs.version }}.md > /tmp/release-notes.txt - - uses: goreleaser/goreleaser-action@286f3b13b1b49da4ac219696163fb8c1c93e1200 # v6.0.0 + - uses: goreleaser/goreleaser-action@9ed2f89a662bf1735a48bc8557fd212fa902bebf # v6.1.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 11c6079f..18146462 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +## 0.16.0 (December 12, 2024) + +FEATURES: + +* listvalidator: Added `NoNullValues` validator ([#245](https://github.com/hashicorp/terraform-plugin-framework-validators/issues/245)) +* mapvalidator: Added `NoNullValues` validator ([#245](https://github.com/hashicorp/terraform-plugin-framework-validators/issues/245)) +* setvalidator: Added `NoNullValues` validator ([#245](https://github.com/hashicorp/terraform-plugin-framework-validators/issues/245)) +* dynamicvalidator: New package which contains `types.Dynamic` specific validators ([#249](https://github.com/hashicorp/terraform-plugin-framework-validators/issues/249)) + +BUG FIXES: + +* Fixed bug with `ConflictsWith` and `AlsoRequires` validators where unknown values would raise invalid diagnostics during `terraform validate`. ([#251](https://github.com/hashicorp/terraform-plugin-framework-validators/issues/251)) + ## 0.15.0 (October 31, 2024) FEATURES: diff --git a/dynamicvalidator/all.go b/dynamicvalidator/all.go new file mode 100644 index 00000000..7f29e2ae --- /dev/null +++ b/dynamicvalidator/all.go @@ -0,0 +1,57 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package dynamicvalidator + +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.Dynamic) validator.Dynamic { + return allValidator{ + validators: validators, + } +} + +var _ validator.Dynamic = allValidator{} + +// allValidator implements the validator. +type allValidator struct { + validators []validator.Dynamic +} + +// 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) +} + +// ValidateDynamic performs the validation. +func (v allValidator) ValidateDynamic(ctx context.Context, req validator.DynamicRequest, resp *validator.DynamicResponse) { + for _, subValidator := range v.validators { + validateResp := &validator.DynamicResponse{} + + subValidator.ValidateDynamic(ctx, req, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) + } +} diff --git a/dynamicvalidator/all_example_test.go b/dynamicvalidator/all_example_test.go new file mode 100644 index 00000000..e1de7cfb --- /dev/null +++ b/dynamicvalidator/all_example_test.go @@ -0,0 +1,27 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package dynamicvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/dynamicvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleAll() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.DynamicAttribute{ + Required: true, + Validators: []validator.Dynamic{ + dynamicvalidator.Any( + dynamicvalidator.Any( /* ... */ ), + dynamicvalidator.All( /* ... */ ), + ), + }, + }, + }, + } +} diff --git a/dynamicvalidator/also_requires.go b/dynamicvalidator/also_requires.go new file mode 100644 index 00000000..15113d78 --- /dev/null +++ b/dynamicvalidator/also_requires.go @@ -0,0 +1,26 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package dynamicvalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// AlsoRequires checks that a set of path.Expression has a non-null value, +// if the current attribute or block also has a non-null value. +// +// This implements the validation logic declaratively within the schema. +// Refer to [datasourcevalidator.RequiredTogether], +// [providervalidator.RequiredTogether], or [resourcevalidator.RequiredTogether] +// for declaring this type of validation outside the schema definition. +// +// Relative path.Expression will be resolved using the attribute or block +// being validated. +func AlsoRequires(expressions ...path.Expression) validator.Dynamic { + return schemavalidator.AlsoRequiresValidator{ + PathExpressions: expressions, + } +} diff --git a/dynamicvalidator/also_requires_example_test.go b/dynamicvalidator/also_requires_example_test.go new file mode 100644 index 00000000..4d4bb261 --- /dev/null +++ b/dynamicvalidator/also_requires_example_test.go @@ -0,0 +1,31 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package dynamicvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/dynamicvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleAlsoRequires() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.DynamicAttribute{ + Optional: true, + Validators: []validator.Dynamic{ + // Validate this attribute must be configured with other_attr. + dynamicvalidator.AlsoRequires(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.DynamicAttribute{ + Optional: true, + }, + }, + } +} diff --git a/dynamicvalidator/any.go b/dynamicvalidator/any.go new file mode 100644 index 00000000..42086fcf --- /dev/null +++ b/dynamicvalidator/any.go @@ -0,0 +1,65 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package dynamicvalidator + +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.Dynamic) validator.Dynamic { + return anyValidator{ + validators: validators, + } +} + +var _ validator.Dynamic = anyValidator{} + +// anyValidator implements the validator. +type anyValidator struct { + validators []validator.Dynamic +} + +// 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) +} + +// ValidateDynamic performs the validation. +func (v anyValidator) ValidateDynamic(ctx context.Context, req validator.DynamicRequest, resp *validator.DynamicResponse) { + for _, subValidator := range v.validators { + validateResp := &validator.DynamicResponse{} + + subValidator.ValidateDynamic(ctx, req, validateResp) + + if !validateResp.Diagnostics.HasError() { + resp.Diagnostics = validateResp.Diagnostics + + return + } + + resp.Diagnostics.Append(validateResp.Diagnostics...) + } +} diff --git a/dynamicvalidator/any_example_test.go b/dynamicvalidator/any_example_test.go new file mode 100644 index 00000000..b7cce6d6 --- /dev/null +++ b/dynamicvalidator/any_example_test.go @@ -0,0 +1,26 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package dynamicvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/dynamicvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleAny() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.DynamicAttribute{ + Required: true, + Validators: []validator.Dynamic{ + dynamicvalidator.Any( + dynamicvalidator.Any( /* ... */ ), + ), + }, + }, + }, + } +} diff --git a/dynamicvalidator/any_with_all_warnings.go b/dynamicvalidator/any_with_all_warnings.go new file mode 100644 index 00000000..c1614798 --- /dev/null +++ b/dynamicvalidator/any_with_all_warnings.go @@ -0,0 +1,67 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package dynamicvalidator + +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.Dynamic) validator.Dynamic { + return anyWithAllWarningsValidator{ + validators: validators, + } +} + +var _ validator.Dynamic = anyWithAllWarningsValidator{} + +// anyWithAllWarningsValidator implements the validator. +type anyWithAllWarningsValidator struct { + validators []validator.Dynamic +} + +// 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) +} + +// ValidateDynamic performs the validation. +func (v anyWithAllWarningsValidator) ValidateDynamic(ctx context.Context, req validator.DynamicRequest, resp *validator.DynamicResponse) { + anyValid := false + + for _, subValidator := range v.validators { + validateResp := &validator.DynamicResponse{} + + subValidator.ValidateDynamic(ctx, req, validateResp) + + if !validateResp.Diagnostics.HasError() { + anyValid = true + } + + resp.Diagnostics.Append(validateResp.Diagnostics...) + } + + if anyValid { + resp.Diagnostics = resp.Diagnostics.Warnings() + } +} diff --git a/dynamicvalidator/any_with_all_warnings_example_test.go b/dynamicvalidator/any_with_all_warnings_example_test.go new file mode 100644 index 00000000..8e7ebb85 --- /dev/null +++ b/dynamicvalidator/any_with_all_warnings_example_test.go @@ -0,0 +1,26 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package dynamicvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/dynamicvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleAnyWithAllWarnings() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.DynamicAttribute{ + Required: true, + Validators: []validator.Dynamic{ + dynamicvalidator.AnyWithAllWarnings( + dynamicvalidator.AnyWithAllWarnings( /* ... */ ), + ), + }, + }, + }, + } +} diff --git a/dynamicvalidator/at_least_one_of.go b/dynamicvalidator/at_least_one_of.go new file mode 100644 index 00000000..7a833fd4 --- /dev/null +++ b/dynamicvalidator/at_least_one_of.go @@ -0,0 +1,27 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package dynamicvalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// AtLeastOneOf checks that of a set of path.Expression, +// including the attribute this validator is applied to, +// at least one has a non-null value. +// +// This implements the validation logic declaratively within the tfsdk.Schema. +// Refer to [datasourcevalidator.AtLeastOneOf], +// [providervalidator.AtLeastOneOf], or [resourcevalidator.AtLeastOneOf] +// for declaring this type of validation outside the schema definition. +// +// Any relative path.Expression will be resolved using the attribute being +// validated. +func AtLeastOneOf(expressions ...path.Expression) validator.Dynamic { + return schemavalidator.AtLeastOneOfValidator{ + PathExpressions: expressions, + } +} diff --git a/dynamicvalidator/at_least_one_of_example_test.go b/dynamicvalidator/at_least_one_of_example_test.go new file mode 100644 index 00000000..6e64bbc4 --- /dev/null +++ b/dynamicvalidator/at_least_one_of_example_test.go @@ -0,0 +1,31 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package dynamicvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/dynamicvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleAtLeastOneOf() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.DynamicAttribute{ + Optional: true, + Validators: []validator.Dynamic{ + // Validate at least this attribute or other_attr should be configured. + dynamicvalidator.AtLeastOneOf(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.DynamicAttribute{ + Optional: true, + }, + }, + } +} diff --git a/dynamicvalidator/conflicts_with.go b/dynamicvalidator/conflicts_with.go new file mode 100644 index 00000000..1bda81c2 --- /dev/null +++ b/dynamicvalidator/conflicts_with.go @@ -0,0 +1,27 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package dynamicvalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// ConflictsWith checks that a set of path.Expression, +// including the attribute the validator is applied to, +// do not have a value simultaneously. +// +// This implements the validation logic declaratively within the schema. +// Refer to [datasourcevalidator.Conflicting], +// [providervalidator.Conflicting], or [resourcevalidator.Conflicting] +// for declaring this type of validation outside the schema definition. +// +// Relative path.Expression will be resolved using the attribute being +// validated. +func ConflictsWith(expressions ...path.Expression) validator.Dynamic { + return schemavalidator.ConflictsWithValidator{ + PathExpressions: expressions, + } +} diff --git a/dynamicvalidator/conflicts_with_example_test.go b/dynamicvalidator/conflicts_with_example_test.go new file mode 100644 index 00000000..268f765a --- /dev/null +++ b/dynamicvalidator/conflicts_with_example_test.go @@ -0,0 +1,31 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package dynamicvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/dynamicvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleConflictsWith() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.DynamicAttribute{ + Optional: true, + Validators: []validator.Dynamic{ + // Validate this attribute must not be configured with other_attr. + dynamicvalidator.ConflictsWith(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.DynamicAttribute{ + Optional: true, + }, + }, + } +} diff --git a/dynamicvalidator/doc.go b/dynamicvalidator/doc.go new file mode 100644 index 00000000..de534e26 --- /dev/null +++ b/dynamicvalidator/doc.go @@ -0,0 +1,5 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +// Package dynamicvalidator provides validators for types.Dynamic attributes and function parameters. +package dynamicvalidator diff --git a/dynamicvalidator/exactly_one_of.go b/dynamicvalidator/exactly_one_of.go new file mode 100644 index 00000000..a501c7e8 --- /dev/null +++ b/dynamicvalidator/exactly_one_of.go @@ -0,0 +1,28 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package dynamicvalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/schemavalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// ExactlyOneOf checks that of a set of path.Expression, +// including the attribute the validator is applied to, +// one and only one attribute has a value. +// It will also cause a validation error if none are specified. +// +// This implements the validation logic declaratively within the schema. +// Refer to [datasourcevalidator.ExactlyOneOf], +// [providervalidator.ExactlyOneOf], or [resourcevalidator.ExactlyOneOf] +// for declaring this type of validation outside the schema definition. +// +// Relative path.Expression will be resolved using the attribute being +// validated. +func ExactlyOneOf(expressions ...path.Expression) validator.Dynamic { + return schemavalidator.ExactlyOneOfValidator{ + PathExpressions: expressions, + } +} diff --git a/dynamicvalidator/exactly_one_of_example_test.go b/dynamicvalidator/exactly_one_of_example_test.go new file mode 100644 index 00000000..47be7cc8 --- /dev/null +++ b/dynamicvalidator/exactly_one_of_example_test.go @@ -0,0 +1,31 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package dynamicvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/dynamicvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func ExampleExactlyOneOf() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.DynamicAttribute{ + Optional: true, + Validators: []validator.Dynamic{ + // Validate only this attribute or other_attr is configured. + dynamicvalidator.ExactlyOneOf(path.Expressions{ + path.MatchRoot("other_attr"), + }...), + }, + }, + "other_attr": schema.DynamicAttribute{ + Optional: true, + }, + }, + } +} diff --git a/internal/schemavalidator/also_requires.go b/internal/schemavalidator/also_requires.go index 7abc8ae9..f2de2828 100644 --- a/internal/schemavalidator/also_requires.go +++ b/internal/schemavalidator/also_requires.go @@ -29,6 +29,7 @@ var ( _ validator.Object = AlsoRequiresValidator{} _ validator.Set = AlsoRequiresValidator{} _ validator.String = AlsoRequiresValidator{} + _ validator.Dynamic = AlsoRequiresValidator{} ) // AlsoRequiresValidator is the underlying struct implementing AlsoRequires. @@ -57,7 +58,8 @@ func (av AlsoRequiresValidator) MarkdownDescription(_ context.Context) string { func (av AlsoRequiresValidator) Validate(ctx context.Context, req AlsoRequiresValidatorRequest, res *AlsoRequiresValidatorResponse) { // If attribute configuration is null, there is nothing else to validate - if req.ConfigValue.IsNull() { + // If attribute configuration is unknown, delay the validation until it is known. + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { return } @@ -257,3 +259,17 @@ func (av AlsoRequiresValidator) ValidateString(ctx context.Context, req validato resp.Diagnostics.Append(validateResp.Diagnostics...) } + +func (av AlsoRequiresValidator) ValidateDynamic(ctx context.Context, req validator.DynamicRequest, resp *validator.DynamicResponse) { + validateReq := AlsoRequiresValidatorRequest{ + Config: req.Config, + ConfigValue: req.ConfigValue, + Path: req.Path, + PathExpression: req.PathExpression, + } + validateResp := &AlsoRequiresValidatorResponse{} + + av.Validate(ctx, validateReq, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) +} diff --git a/internal/schemavalidator/also_requires_test.go b/internal/schemavalidator/also_requires_test.go index 6c7da078..3ea0d40b 100644 --- a/internal/schemavalidator/also_requires_test.go +++ b/internal/schemavalidator/also_requires_test.go @@ -80,6 +80,33 @@ func TestAlsoRequiresValidatorValidate(t *testing.T) { path.MatchRoot("foo"), }, }, + "self-is-unknown": { + req: schemavalidator.AlsoRequiresValidatorRequest{ + ConfigValue: types.StringUnknown(), + Path: path.Root("bar"), + PathExpression: path.MatchRoot("bar"), + Config: tfsdk.Config{ + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "foo": schema.Int64Attribute{}, + "bar": schema.StringAttribute{}, + }, + }, + Raw: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "foo": tftypes.Number, + "bar": tftypes.String, + }, + }, map[string]tftypes.Value{ + "foo": tftypes.NewValue(tftypes.Number, nil), + "bar": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + }), + }, + }, + in: path.Expressions{ + path.MatchRoot("foo"), + }, + }, "error_missing-one": { req: schemavalidator.AlsoRequiresValidatorRequest{ ConfigValue: types.StringValue("bar value"), diff --git a/internal/schemavalidator/at_least_one_of.go b/internal/schemavalidator/at_least_one_of.go index a31e4507..c68ea841 100644 --- a/internal/schemavalidator/at_least_one_of.go +++ b/internal/schemavalidator/at_least_one_of.go @@ -29,6 +29,7 @@ var ( _ validator.Object = AtLeastOneOfValidator{} _ validator.Set = AtLeastOneOfValidator{} _ validator.String = AtLeastOneOfValidator{} + _ validator.Dynamic = AtLeastOneOfValidator{} ) // AtLeastOneOfValidator is the underlying struct implementing AtLeastOneOf. @@ -57,6 +58,7 @@ func (av AtLeastOneOfValidator) MarkdownDescription(_ context.Context) string { func (av AtLeastOneOfValidator) Validate(ctx context.Context, req AtLeastOneOfValidatorRequest, res *AtLeastOneOfValidatorResponse) { // If attribute configuration is not null, validator already succeeded. + // If attribute configuration is unknown, delay the validation until it is known. if !req.ConfigValue.IsNull() { return } @@ -257,3 +259,17 @@ func (av AtLeastOneOfValidator) ValidateString(ctx context.Context, req validato resp.Diagnostics.Append(validateResp.Diagnostics...) } + +func (av AtLeastOneOfValidator) ValidateDynamic(ctx context.Context, req validator.DynamicRequest, resp *validator.DynamicResponse) { + validateReq := AtLeastOneOfValidatorRequest{ + Config: req.Config, + ConfigValue: req.ConfigValue, + Path: req.Path, + PathExpression: req.PathExpression, + } + validateResp := &AtLeastOneOfValidatorResponse{} + + av.Validate(ctx, validateReq, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) +} diff --git a/internal/schemavalidator/at_least_one_of_test.go b/internal/schemavalidator/at_least_one_of_test.go index b9a88e34..ce2122cf 100644 --- a/internal/schemavalidator/at_least_one_of_test.go +++ b/internal/schemavalidator/at_least_one_of_test.go @@ -84,6 +84,34 @@ func TestAtLeastOneOfValidatorValidate(t *testing.T) { }, expected: &schemavalidator.AtLeastOneOfValidatorResponse{}, }, + "self-is-unknown": { + req: schemavalidator.AtLeastOneOfValidatorRequest{ + ConfigValue: types.StringUnknown(), + Path: path.Root("bar"), + PathExpression: path.MatchRoot("bar"), + Config: tfsdk.Config{ + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "foo": schema.Int64Attribute{}, + "bar": schema.StringAttribute{}, + }, + }, + Raw: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "foo": tftypes.Number, + "bar": tftypes.String, + }, + }, map[string]tftypes.Value{ + "foo": tftypes.NewValue(tftypes.Number, 42), + "bar": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + }), + }, + }, + in: path.Expressions{ + path.MatchRoot("foo"), + }, + expected: &schemavalidator.AtLeastOneOfValidatorResponse{}, + }, "error_none-set": { req: schemavalidator.AtLeastOneOfValidatorRequest{ ConfigValue: types.StringNull(), diff --git a/internal/schemavalidator/conflicts_with.go b/internal/schemavalidator/conflicts_with.go index 185d58e9..9cbc096c 100644 --- a/internal/schemavalidator/conflicts_with.go +++ b/internal/schemavalidator/conflicts_with.go @@ -29,6 +29,7 @@ var ( _ validator.Object = ConflictsWithValidator{} _ validator.Set = ConflictsWithValidator{} _ validator.String = ConflictsWithValidator{} + _ validator.Dynamic = ConflictsWithValidator{} ) // ConflictsWithValidator is the underlying struct implementing ConflictsWith. @@ -57,7 +58,8 @@ func (av ConflictsWithValidator) MarkdownDescription(_ context.Context) string { func (av ConflictsWithValidator) Validate(ctx context.Context, req ConflictsWithValidatorRequest, res *ConflictsWithValidatorResponse) { // If attribute configuration is null, it cannot conflict with others - if req.ConfigValue.IsNull() { + // If attribute configuration is unknown, delay the validation until it is known. + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { return } @@ -257,3 +259,17 @@ func (av ConflictsWithValidator) ValidateString(ctx context.Context, req validat resp.Diagnostics.Append(validateResp.Diagnostics...) } + +func (av ConflictsWithValidator) ValidateDynamic(ctx context.Context, req validator.DynamicRequest, resp *validator.DynamicResponse) { + validateReq := ConflictsWithValidatorRequest{ + Config: req.Config, + ConfigValue: req.ConfigValue, + Path: req.Path, + PathExpression: req.PathExpression, + } + validateResp := &ConflictsWithValidatorResponse{} + + av.Validate(ctx, validateReq, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) +} diff --git a/internal/schemavalidator/conflicts_with_test.go b/internal/schemavalidator/conflicts_with_test.go index 92b9c37b..faa45a9c 100644 --- a/internal/schemavalidator/conflicts_with_test.go +++ b/internal/schemavalidator/conflicts_with_test.go @@ -139,6 +139,33 @@ func TestConflictsWithValidatorValidate(t *testing.T) { path.MatchRoot("foo"), }, }, + "self-is-unknown": { + req: schemavalidator.ConflictsWithValidatorRequest{ + ConfigValue: types.StringUnknown(), + Path: path.Root("bar"), + PathExpression: path.MatchRoot("bar"), + Config: tfsdk.Config{ + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "foo": schema.Int64Attribute{}, + "bar": schema.StringAttribute{}, + }, + }, + Raw: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "foo": tftypes.Number, + "bar": tftypes.String, + }, + }, map[string]tftypes.Value{ + "foo": tftypes.NewValue(tftypes.Number, 42), + "bar": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + }), + }, + }, + in: path.Expressions{ + path.MatchRoot("foo"), + }, + }, "error_allow-duplicate-input": { req: schemavalidator.ConflictsWithValidatorRequest{ ConfigValue: types.StringValue("bar value"), diff --git a/internal/schemavalidator/exactly_one_of.go b/internal/schemavalidator/exactly_one_of.go index 40af4383..b4714a71 100644 --- a/internal/schemavalidator/exactly_one_of.go +++ b/internal/schemavalidator/exactly_one_of.go @@ -29,6 +29,7 @@ var ( _ validator.Object = ExactlyOneOfValidator{} _ validator.Set = ExactlyOneOfValidator{} _ validator.String = ExactlyOneOfValidator{} + _ validator.Dynamic = ExactlyOneOfValidator{} ) // ExactlyOneOfValidator is the underlying struct implementing ExactlyOneOf. @@ -277,3 +278,17 @@ func (av ExactlyOneOfValidator) ValidateString(ctx context.Context, req validato resp.Diagnostics.Append(validateResp.Diagnostics...) } + +func (av ExactlyOneOfValidator) ValidateDynamic(ctx context.Context, req validator.DynamicRequest, resp *validator.DynamicResponse) { + validateReq := ExactlyOneOfValidatorRequest{ + Config: req.Config, + ConfigValue: req.ConfigValue, + Path: req.Path, + PathExpression: req.PathExpression, + } + validateResp := &ExactlyOneOfValidatorResponse{} + + av.Validate(ctx, validateReq, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) +} diff --git a/internal/schemavalidator/exactly_one_of_test.go b/internal/schemavalidator/exactly_one_of_test.go index c0c82b6d..cb6dd1b2 100644 --- a/internal/schemavalidator/exactly_one_of_test.go +++ b/internal/schemavalidator/exactly_one_of_test.go @@ -81,6 +81,33 @@ func TestExactlyOneOfValidator(t *testing.T) { path.MatchRoot("foo"), }, }, + "self-is-unknown": { + req: schemavalidator.ExactlyOneOfValidatorRequest{ + ConfigValue: types.StringUnknown(), + Path: path.Root("bar"), + PathExpression: path.MatchRoot("bar"), + Config: tfsdk.Config{ + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "foo": schema.Int64Attribute{}, + "bar": schema.StringAttribute{}, + }, + }, + Raw: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "foo": tftypes.Number, + "bar": tftypes.String, + }, + }, map[string]tftypes.Value{ + "foo": tftypes.NewValue(tftypes.Number, 42), + "bar": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + }), + }, + }, + in: path.Expressions{ + path.MatchRoot("foo"), + }, + }, "error_too-many": { req: schemavalidator.ExactlyOneOfValidatorRequest{ ConfigValue: types.StringValue("bar value"), diff --git a/listvalidator/no_null_values.go b/listvalidator/no_null_values.go new file mode 100644 index 00000000..a66e8b0b --- /dev/null +++ b/listvalidator/no_null_values.go @@ -0,0 +1,78 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package listvalidator + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/function" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +var _ validator.List = noNullValuesValidator{} +var _ function.ListParameterValidator = noNullValuesValidator{} + +type noNullValuesValidator struct{} + +func (v noNullValuesValidator) Description(_ context.Context) string { + return "All values in the list must be configured" +} + +func (v noNullValuesValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +func (v noNullValuesValidator) ValidateList(_ context.Context, req validator.ListRequest, resp *validator.ListResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + elements := req.ConfigValue.Elements() + + for _, e := range elements { + // Only evaluate known values for null + if e.IsUnknown() { + continue + } + + if e.IsNull() { + resp.Diagnostics.AddAttributeError( + req.Path, + "Null List Value", + "This attribute contains a null value.", + ) + } + } +} + +func (v noNullValuesValidator) ValidateParameterList(ctx context.Context, req function.ListParameterValidatorRequest, resp *function.ListParameterValidatorResponse) { + if req.Value.IsNull() || req.Value.IsUnknown() { + return + } + + elements := req.Value.Elements() + + for _, e := range elements { + // Only evaluate known values for null + if e.IsUnknown() { + continue + } + + if e.IsNull() { + resp.Error = function.ConcatFuncErrors( + resp.Error, + function.NewArgumentFuncError( + req.ArgumentPosition, + "Null List Value: This attribute contains a null value.", + ), + ) + } + } +} + +// NoNullValues returns a validator which ensures that any configured list +// only contains non-null values. +func NoNullValues() noNullValuesValidator { + return noNullValuesValidator{} +} diff --git a/listvalidator/no_null_values_example_test.go b/listvalidator/no_null_values_example_test.go new file mode 100644 index 00000000..c141daec --- /dev/null +++ b/listvalidator/no_null_values_example_test.go @@ -0,0 +1,42 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package listvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/function" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ExampleNoNullValues() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.ListAttribute{ + ElementType: types.StringType, + Required: true, + Validators: []validator.List{ + // Validate this list must contain no null values. + listvalidator.NoNullValues(), + }, + }, + }, + } +} + +func ExampleNoNullValues_function() { + _ = function.Definition{ + Parameters: []function.Parameter{ + function.ListParameter{ + Name: "example_param", + Validators: []function.ListParameterValidator{ + // Validate this list must contain no null values. + listvalidator.NoNullValues(), + }, + }, + }, + } +} diff --git a/listvalidator/no_null_values_test.go b/listvalidator/no_null_values_test.go new file mode 100644 index 00000000..f2ae0280 --- /dev/null +++ b/listvalidator/no_null_values_test.go @@ -0,0 +1,109 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package listvalidator_test + +import ( + "context" + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/function" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestNoNullValuesValidator(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.List + expectError bool + } + tests := map[string]testCase{ + "List unknown": { + val: types.ListUnknown( + types.StringType, + ), + expectError: false, + }, + "List null": { + val: types.ListNull( + types.StringType, + ), + expectError: false, + }, + "No null values": { + val: types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + expectError: false, + }, + "Unknown value": { + val: types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringUnknown(), + }, + ), + expectError: false, + }, + "Null value": { + val: types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringNull(), + }, + ), + expectError: true, + }, + } + + for name, test := range tests { + t.Run(fmt.Sprintf("ValidateList - %s", name), func(t *testing.T) { + t.Parallel() + request := validator.ListRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + } + response := validator.ListResponse{} + listvalidator.NoNullValues().ValidateList(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) + } + }) + + t.Run(fmt.Sprintf("ValidateParameterList - %s", name), func(t *testing.T) { + t.Parallel() + request := function.ListParameterValidatorRequest{ + ArgumentPosition: 0, + Value: test.val, + } + response := function.ListParameterValidatorResponse{} + listvalidator.NoNullValues().ValidateParameterList(context.TODO(), request, &response) + + if response.Error == nil && test.expectError { + t.Fatal("expected error, got no error") + } + + if response.Error != nil && !test.expectError { + t.Fatalf("got unexpected error: %s", response.Error) + } + }) + } +} diff --git a/mapvalidator/no_null_values.go b/mapvalidator/no_null_values.go new file mode 100644 index 00000000..0c9eb69d --- /dev/null +++ b/mapvalidator/no_null_values.go @@ -0,0 +1,78 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package mapvalidator + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/function" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +var _ validator.Map = noNullValuesValidator{} +var _ function.MapParameterValidator = noNullValuesValidator{} + +type noNullValuesValidator struct{} + +func (v noNullValuesValidator) Description(_ context.Context) string { + return "All values in the map must be configured" +} + +func (v noNullValuesValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +func (v noNullValuesValidator) ValidateMap(_ context.Context, req validator.MapRequest, resp *validator.MapResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + elements := req.ConfigValue.Elements() + + for _, e := range elements { + // Only evaluate known values for null + if e.IsUnknown() { + continue + } + + if e.IsNull() { + resp.Diagnostics.AddAttributeError( + req.Path, + "Null Map Value", + "This attribute contains a null value.", + ) + } + } +} + +func (v noNullValuesValidator) ValidateParameterMap(ctx context.Context, req function.MapParameterValidatorRequest, resp *function.MapParameterValidatorResponse) { + if req.Value.IsNull() || req.Value.IsUnknown() { + return + } + + elements := req.Value.Elements() + + for _, e := range elements { + // Only evaluate known values for null + if e.IsUnknown() { + continue + } + + if e.IsNull() { + resp.Error = function.ConcatFuncErrors( + resp.Error, + function.NewArgumentFuncError( + req.ArgumentPosition, + "Null Map Value: This attribute contains a null value.", + ), + ) + } + } +} + +// NoNullValues returns a validator which ensures that any configured map +// only contains non-null values. +func NoNullValues() noNullValuesValidator { + return noNullValuesValidator{} +} diff --git a/mapvalidator/no_null_values_example_test.go b/mapvalidator/no_null_values_example_test.go new file mode 100644 index 00000000..3a1604d4 --- /dev/null +++ b/mapvalidator/no_null_values_example_test.go @@ -0,0 +1,42 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package mapvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/function" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ExampleNoNullValues() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.MapAttribute{ + ElementType: types.StringType, + Required: true, + Validators: []validator.Map{ + // Validate this map must contain no null values. + mapvalidator.NoNullValues(), + }, + }, + }, + } +} + +func ExampleNoNullValues_function() { + _ = function.Definition{ + Parameters: []function.Parameter{ + function.MapParameter{ + Name: "example_param", + Validators: []function.MapParameterValidator{ + // Validate this map must contain no null values. + mapvalidator.NoNullValues(), + }, + }, + }, + } +} diff --git a/mapvalidator/no_null_values_test.go b/mapvalidator/no_null_values_test.go new file mode 100644 index 00000000..18e05e9d --- /dev/null +++ b/mapvalidator/no_null_values_test.go @@ -0,0 +1,109 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package mapvalidator_test + +import ( + "context" + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/function" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestNoNullValuesValidator(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.Map + expectError bool + } + tests := map[string]testCase{ + "Map unknown": { + val: types.MapUnknown( + types.StringType, + ), + expectError: false, + }, + "Map null": { + val: types.MapNull( + types.StringType, + ), + expectError: false, + }, + "No null values": { + val: types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("first"), + "key2": types.StringValue("second"), + }, + ), + expectError: false, + }, + "Unknown value": { + val: types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("first"), + "key2": types.StringUnknown(), + }, + ), + expectError: false, + }, + "Null value": { + val: types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("first"), + "key2": types.StringNull(), + }, + ), + expectError: true, + }, + } + + for name, test := range tests { + t.Run(fmt.Sprintf("ValidateMap - %s", name), func(t *testing.T) { + t.Parallel() + request := validator.MapRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + } + response := validator.MapResponse{} + mapvalidator.NoNullValues().ValidateMap(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) + } + }) + + t.Run(fmt.Sprintf("ValidateParameterMap - %s", name), func(t *testing.T) { + t.Parallel() + request := function.MapParameterValidatorRequest{ + ArgumentPosition: 0, + Value: test.val, + } + response := function.MapParameterValidatorResponse{} + mapvalidator.NoNullValues().ValidateParameterMap(context.TODO(), request, &response) + + if response.Error == nil && test.expectError { + t.Fatal("expected error, got no error") + } + + if response.Error != nil && !test.expectError { + t.Fatalf("got unexpected error: %s", response.Error) + } + }) + } +} diff --git a/setvalidator/no_null_values.go b/setvalidator/no_null_values.go new file mode 100644 index 00000000..a4ae414c --- /dev/null +++ b/setvalidator/no_null_values.go @@ -0,0 +1,78 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package setvalidator + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/function" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +var _ validator.Set = noNullValuesValidator{} +var _ function.SetParameterValidator = noNullValuesValidator{} + +type noNullValuesValidator struct{} + +func (v noNullValuesValidator) Description(_ context.Context) string { + return "All values in the set must be configured" +} + +func (v noNullValuesValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +func (v noNullValuesValidator) ValidateSet(_ context.Context, req validator.SetRequest, resp *validator.SetResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + elements := req.ConfigValue.Elements() + + for _, e := range elements { + // Only evaluate known values for null + if e.IsUnknown() { + continue + } + + if e.IsNull() { + resp.Diagnostics.AddAttributeError( + req.Path, + "Null Set Value", + "This attribute contains a null value.", + ) + } + } +} + +func (v noNullValuesValidator) ValidateParameterSet(ctx context.Context, req function.SetParameterValidatorRequest, resp *function.SetParameterValidatorResponse) { + if req.Value.IsNull() || req.Value.IsUnknown() { + return + } + + elements := req.Value.Elements() + + for _, e := range elements { + // Only evaluate known values for null + if e.IsUnknown() { + continue + } + + if e.IsNull() { + resp.Error = function.ConcatFuncErrors( + resp.Error, + function.NewArgumentFuncError( + req.ArgumentPosition, + "Null Set Value: This attribute contains a null value.", + ), + ) + } + } +} + +// NoNullValues returns a validator which ensures that any configured set +// only contains non-null values. +func NoNullValues() noNullValuesValidator { + return noNullValuesValidator{} +} diff --git a/setvalidator/no_null_values_example_test.go b/setvalidator/no_null_values_example_test.go new file mode 100644 index 00000000..2e1de727 --- /dev/null +++ b/setvalidator/no_null_values_example_test.go @@ -0,0 +1,42 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package setvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/function" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ExampleNoNullValues() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.SetAttribute{ + ElementType: types.StringType, + Required: true, + Validators: []validator.Set{ + // Validate this set must contain no null values. + setvalidator.NoNullValues(), + }, + }, + }, + } +} + +func ExampleNoNullValues_function() { + _ = function.Definition{ + Parameters: []function.Parameter{ + function.SetParameter{ + Name: "example_param", + Validators: []function.SetParameterValidator{ + // Validate this set must contain no null values. + setvalidator.NoNullValues(), + }, + }, + }, + } +} diff --git a/setvalidator/no_null_values_test.go b/setvalidator/no_null_values_test.go new file mode 100644 index 00000000..7ae47fe3 --- /dev/null +++ b/setvalidator/no_null_values_test.go @@ -0,0 +1,109 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package setvalidator_test + +import ( + "context" + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/function" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestNoNullValuesValidator(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.Set + expectError bool + } + tests := map[string]testCase{ + "Set unknown": { + val: types.SetUnknown( + types.StringType, + ), + expectError: false, + }, + "Set null": { + val: types.SetNull( + types.StringType, + ), + expectError: false, + }, + "No null values": { + val: types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + expectError: false, + }, + "Unknown value": { + val: types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringUnknown(), + }, + ), + expectError: false, + }, + "Null value": { + val: types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringNull(), + }, + ), + expectError: true, + }, + } + + for name, test := range tests { + t.Run(fmt.Sprintf("ValidateSet - %s", name), func(t *testing.T) { + t.Parallel() + request := validator.SetRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + } + response := validator.SetResponse{} + setvalidator.NoNullValues().ValidateSet(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) + } + }) + + t.Run(fmt.Sprintf("ValidateParameterSet - %s", name), func(t *testing.T) { + t.Parallel() + request := function.SetParameterValidatorRequest{ + ArgumentPosition: 0, + Value: test.val, + } + response := function.SetParameterValidatorResponse{} + setvalidator.NoNullValues().ValidateParameterSet(context.TODO(), request, &response) + + if response.Error == nil && test.expectError { + t.Fatal("expected error, got no error") + } + + if response.Error != nil && !test.expectError { + t.Fatalf("got unexpected error: %s", response.Error) + } + }) + } +} diff --git a/tools/go.mod b/tools/go.mod index 9938d4ca..738e2a3d 100644 --- a/tools/go.mod +++ b/tools/go.mod @@ -18,7 +18,7 @@ require ( github.com/fsnotify/fsnotify v1.5.4 // indirect github.com/go-openapi/errors v0.20.2 // indirect github.com/go-openapi/strfmt v0.21.3 // indirect - github.com/golang-jwt/jwt/v4 v4.5.0 // indirect + github.com/golang-jwt/jwt/v4 v4.5.1 // indirect github.com/golang/protobuf v1.5.2 // indirect github.com/google/go-github/v45 v45.2.0 // indirect github.com/google/go-github/v53 v53.0.0 // indirect diff --git a/tools/go.sum b/tools/go.sum index 0d402390..cfc168fa 100644 --- a/tools/go.sum +++ b/tools/go.sum @@ -97,8 +97,9 @@ github.com/go-test/deep v1.0.2-0.20181118220953-042da051cf31/go.mod h1:wGDj63lr6 github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo= +github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=