diff --git a/.changes/0.15.0.md b/.changes/0.15.0.md new file mode 100644 index 00000000..dbb9e23d --- /dev/null +++ b/.changes/0.15.0.md @@ -0,0 +1,6 @@ +## 0.15.0 (October 31, 2024) + +FEATURES: + +* ephemeralvalidator: Introduce new package with declarative validators for ephemeral resource configurations ([#242](https://github.com/hashicorp/terraform-plugin-framework-validators/issues/242)) + diff --git a/.github/workflows/ci-changie.yml b/.github/workflows/ci-changie.yml index 60a514c0..33ba8ee8 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@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 # 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 2358e4ad..47f1bbb6 100644 --- a/.github/workflows/ci-github-actions.yml +++ b/.github/workflows/ci-github-actions.yml @@ -13,7 +13,7 @@ jobs: actionlint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 with: go-version-file: 'go.mod' diff --git a/.github/workflows/ci-go.yml b/.github/workflows/ci-go.yml index 60928e9b..813d4dbb 100644 --- a/.github/workflows/ci-go.yml +++ b/.github/workflows/ci-go.yml @@ -16,7 +16,7 @@ jobs: golangci-lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 with: go-version-file: 'go.mod' @@ -30,14 +30,14 @@ jobs: matrix: go-version: [ '1.23', '1.22' ] steps: - - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 with: go-version: ${{ matrix.go-version }} - run: go mod download - run: go test -coverprofile=coverage.out ./... - run: go tool cover -html=coverage.out -o coverage.html - - uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0 + - uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 with: name: go-${{ matrix.go-version }}-coverage path: coverage.html diff --git a/.github/workflows/ci-goreleaser.yml b/.github/workflows/ci-goreleaser.yml index d978baa4..9e5193bc 100644 --- a/.github/workflows/ci-goreleaser.yml +++ b/.github/workflows/ci-goreleaser.yml @@ -14,7 +14,7 @@ jobs: check: runs-on: ubuntu-latest steps: - - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 with: go-version-file: 'go.mod' diff --git a/.github/workflows/compliance.yml b/.github/workflows/compliance.yml index ddd4d72a..9511923d 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@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 - 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 e253c38d..4b3613dc 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@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 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@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 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,7 +79,7 @@ jobs: contents: write # Needed for goreleaser to create GitHub release issues: write # Needed for goreleaser to close associated milestone steps: - - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 with: ref: ${{ inputs.versionNumber }} fetch-depth: 0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 82768140..11c6079f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 0.15.0 (October 31, 2024) + +FEATURES: + +* ephemeralvalidator: Introduce new package with declarative validators for ephemeral resource configurations ([#242](https://github.com/hashicorp/terraform-plugin-framework-validators/issues/242)) + ## 0.14.0 (October 17, 2024) NOTES: diff --git a/ephemeralvalidator/all.go b/ephemeralvalidator/all.go new file mode 100644 index 00000000..7ee46787 --- /dev/null +++ b/ephemeralvalidator/all.go @@ -0,0 +1,57 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ephemeralvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/ephemeral" +) + +// All returns a validator which ensures that any configured attribute value +// validates against all the given validators. +// +// Use of All is only necessary when used in conjunction with Any or AnyWithAllWarnings +// as the Validators field automatically applies a logical AND. +func All(validators ...ephemeral.ConfigValidator) ephemeral.ConfigValidator { + return allValidator{ + validators: validators, + } +} + +var _ ephemeral.ConfigValidator = allValidator{} + +// allValidator implements the validator. +type allValidator struct { + validators []ephemeral.ConfigValidator +} + +// Description describes the validation in plain text formatting. +func (v allValidator) Description(ctx context.Context) string { + var descriptions []string + + for _, subValidator := range v.validators { + descriptions = append(descriptions, subValidator.Description(ctx)) + } + + return fmt.Sprintf("Value must satisfy all of the validations: %s", strings.Join(descriptions, " + ")) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (v allValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +// ValidateEphemeralResource performs the validation. +func (v allValidator) ValidateEphemeralResource(ctx context.Context, req ephemeral.ValidateConfigRequest, resp *ephemeral.ValidateConfigResponse) { + for _, subValidator := range v.validators { + validateResp := &ephemeral.ValidateConfigResponse{} + + subValidator.ValidateEphemeralResource(ctx, req, validateResp) + + resp.Diagnostics.Append(validateResp.Diagnostics...) + } +} diff --git a/ephemeralvalidator/all_example_test.go b/ephemeralvalidator/all_example_test.go new file mode 100644 index 00000000..72f8c648 --- /dev/null +++ b/ephemeralvalidator/all_example_test.go @@ -0,0 +1,21 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ephemeralvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + + "github.com/hashicorp/terraform-plugin-framework-validators/ephemeralvalidator" +) + +func ExampleAll() { + // Used inside a ephemeral.EphemeralResource type ConfigValidators method + _ = []ephemeral.ConfigValidator{ + // The configuration must satisfy either All validator. + ephemeralvalidator.Any( + ephemeralvalidator.All( /* ... */ ), + ephemeralvalidator.All( /* ... */ ), + ), + } +} diff --git a/ephemeralvalidator/all_test.go b/ephemeralvalidator/all_test.go new file mode 100644 index 00000000..c8b158bf --- /dev/null +++ b/ephemeralvalidator/all_test.go @@ -0,0 +1,178 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ephemeralvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/ephemeral/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-go/tftypes" + + "github.com/hashicorp/terraform-plugin-framework-validators/ephemeralvalidator" +) + +func TestAllValidatorValidateEphemeralResource(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + validators []ephemeral.ConfigValidator + req ephemeral.ValidateConfigRequest + expected *ephemeral.ValidateConfigResponse + }{ + "no-diagnostics": { + validators: []ephemeral.ConfigValidator{ + ephemeralvalidator.ExactlyOneOf( + path.MatchRoot("test1"), + path.MatchRoot("test2"), + ), + ephemeralvalidator.All( + ephemeralvalidator.AtLeastOneOf( + path.MatchRoot("test3"), + path.MatchRoot("test4"), + ), + ephemeralvalidator.Conflicting( + path.MatchRoot("test3"), + path.MatchRoot("test5"), + ), + ), + }, + req: ephemeral.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ + Optional: true, + }, + "test2": schema.StringAttribute{ + Optional: true, + }, + "test3": schema.StringAttribute{ + Optional: true, + }, + "test4": schema.StringAttribute{ + Optional: true, + }, + "test5": schema.StringAttribute{ + Optional: true, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "test3": tftypes.String, + "test4": tftypes.String, + "test5": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, nil), + "test2": tftypes.NewValue(tftypes.String, nil), + "test3": tftypes.NewValue(tftypes.String, "test-value"), + "test4": tftypes.NewValue(tftypes.String, nil), + "test5": tftypes.NewValue(tftypes.String, nil), + }, + ), + }, + }, + expected: &ephemeral.ValidateConfigResponse{}, + }, + "diagnostics": { + validators: []ephemeral.ConfigValidator{ + ephemeralvalidator.ExactlyOneOf( + path.MatchRoot("test1"), + path.MatchRoot("test2"), + ), + ephemeralvalidator.All( + ephemeralvalidator.AtLeastOneOf( + path.MatchRoot("test3"), + path.MatchRoot("test4"), + ), + ephemeralvalidator.Conflicting( + path.MatchRoot("test3"), + path.MatchRoot("test5"), + ), + ), + }, + req: ephemeral.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ + Optional: true, + }, + "test2": schema.StringAttribute{ + Optional: true, + }, + "test3": schema.StringAttribute{ + Optional: true, + }, + "test4": schema.StringAttribute{ + Optional: true, + }, + "test5": schema.StringAttribute{ + Optional: true, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "test3": tftypes.String, + "test4": tftypes.String, + "test5": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, nil), + "test2": tftypes.NewValue(tftypes.String, nil), + "test3": tftypes.NewValue(tftypes.String, "test-value"), + "test4": tftypes.NewValue(tftypes.String, nil), + "test5": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &ephemeral.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Missing Attribute Configuration", + "Exactly one of these attributes must be configured: [test1,test2]", + ), + diag.WithPath(path.Root("test3"), + diag.NewErrorDiagnostic( + "Invalid Attribute Combination", + "These attributes cannot be configured together: [test3,test5]", + )), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &ephemeral.ValidateConfigResponse{} + + ephemeralvalidator.Any(testCase.validators...).ValidateEphemeralResource(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/ephemeralvalidator/any.go b/ephemeralvalidator/any.go new file mode 100644 index 00000000..4c3ea6e8 --- /dev/null +++ b/ephemeralvalidator/any.go @@ -0,0 +1,65 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ephemeralvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/ephemeral" +) + +// Any returns a validator which ensures that any configured attribute value +// passes at least one of the given validators. +// +// To prevent practitioner confusion should non-passing validators have +// conflicting logic, only warnings from the passing validator are returned. +// Use AnyWithAllWarnings() to return warnings from non-passing validators +// as well. +func Any(validators ...ephemeral.ConfigValidator) ephemeral.ConfigValidator { + return anyValidator{ + validators: validators, + } +} + +var _ ephemeral.ConfigValidator = anyValidator{} + +// anyValidator implements the validator. +type anyValidator struct { + validators []ephemeral.ConfigValidator +} + +// Description describes the validation in plain text formatting. +func (v anyValidator) Description(ctx context.Context) string { + var descriptions []string + + for _, subValidator := range v.validators { + descriptions = append(descriptions, subValidator.Description(ctx)) + } + + return fmt.Sprintf("Value must satisfy at least one of the validations: %s", strings.Join(descriptions, " + ")) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (v anyValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +// ValidateEphemeralResource performs the validation. +func (v anyValidator) ValidateEphemeralResource(ctx context.Context, req ephemeral.ValidateConfigRequest, resp *ephemeral.ValidateConfigResponse) { + for _, subValidator := range v.validators { + validateResp := &ephemeral.ValidateConfigResponse{} + + subValidator.ValidateEphemeralResource(ctx, req, validateResp) + + if !validateResp.Diagnostics.HasError() { + resp.Diagnostics = validateResp.Diagnostics + + return + } + + resp.Diagnostics.Append(validateResp.Diagnostics...) + } +} diff --git a/ephemeralvalidator/any_example_test.go b/ephemeralvalidator/any_example_test.go new file mode 100644 index 00000000..30e98f1d --- /dev/null +++ b/ephemeralvalidator/any_example_test.go @@ -0,0 +1,17 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ephemeralvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + + "github.com/hashicorp/terraform-plugin-framework-validators/ephemeralvalidator" +) + +func ExampleAny() { + // Used inside a ephemeral.EphemeralResource type ConfigValidators method + _ = []ephemeral.ConfigValidator{ + ephemeralvalidator.Any( /* ... */ ), + } +} diff --git a/ephemeralvalidator/any_test.go b/ephemeralvalidator/any_test.go new file mode 100644 index 00000000..aa990286 --- /dev/null +++ b/ephemeralvalidator/any_test.go @@ -0,0 +1,155 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ephemeralvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/ephemeral/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-go/tftypes" + + "github.com/hashicorp/terraform-plugin-framework-validators/ephemeralvalidator" +) + +func TestAnyValidatorValidateEphemeralResource(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + validators []ephemeral.ConfigValidator + req ephemeral.ValidateConfigRequest + expected *ephemeral.ValidateConfigResponse + }{ + "no-diagnostics": { + validators: []ephemeral.ConfigValidator{ + ephemeralvalidator.ExactlyOneOf( + path.MatchRoot("test1"), + path.MatchRoot("test2"), + ), + ephemeralvalidator.ExactlyOneOf( + path.MatchRoot("test3"), + path.MatchRoot("test4"), + ), + }, + req: ephemeral.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ + Optional: true, + }, + "test2": schema.StringAttribute{ + Optional: true, + }, + "test3": schema.StringAttribute{ + Optional: true, + }, + "test4": schema.StringAttribute{ + Optional: true, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "test3": tftypes.String, + "test4": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, nil), + "test2": tftypes.NewValue(tftypes.String, nil), + "test3": tftypes.NewValue(tftypes.String, "test-value"), + "test4": tftypes.NewValue(tftypes.String, nil), + }, + ), + }, + }, + expected: &ephemeral.ValidateConfigResponse{}, + }, + "diagnostics": { + validators: []ephemeral.ConfigValidator{ + ephemeralvalidator.ExactlyOneOf( + path.MatchRoot("test1"), + path.MatchRoot("test2"), + ), + ephemeralvalidator.ExactlyOneOf( + path.MatchRoot("test3"), + path.MatchRoot("test4"), + ), + }, + req: ephemeral.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ + Optional: true, + }, + "test2": schema.StringAttribute{ + Optional: true, + }, + "test3": schema.StringAttribute{ + Optional: true, + }, + "test4": schema.StringAttribute{ + Optional: true, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "test3": tftypes.String, + "test4": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, nil), + "test2": tftypes.NewValue(tftypes.String, nil), + "test3": tftypes.NewValue(tftypes.String, nil), + "test4": tftypes.NewValue(tftypes.String, nil), + }, + ), + }, + }, + expected: &ephemeral.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Missing Attribute Configuration", + "Exactly one of these attributes must be configured: [test1,test2]", + ), + diag.NewErrorDiagnostic( + "Missing Attribute Configuration", + "Exactly one of these attributes must be configured: [test3,test4]", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &ephemeral.ValidateConfigResponse{} + + ephemeralvalidator.Any(testCase.validators...).ValidateEphemeralResource(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/ephemeralvalidator/any_with_all_warnings.go b/ephemeralvalidator/any_with_all_warnings.go new file mode 100644 index 00000000..f840d115 --- /dev/null +++ b/ephemeralvalidator/any_with_all_warnings.go @@ -0,0 +1,67 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ephemeralvalidator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/ephemeral" +) + +// AnyWithAllWarnings returns a validator which ensures that any configured +// attribute value passes at least one of the given validators. This validator +// returns all warnings, including failed validators. +// +// Use Any() to return warnings only from the passing validator. +func AnyWithAllWarnings(validators ...ephemeral.ConfigValidator) ephemeral.ConfigValidator { + return anyWithAllWarningsValidator{ + validators: validators, + } +} + +var _ ephemeral.ConfigValidator = anyWithAllWarningsValidator{} + +// anyWithAllWarningsValidator implements the validator. +type anyWithAllWarningsValidator struct { + validators []ephemeral.ConfigValidator +} + +// Description describes the validation in plain text formatting. +func (v anyWithAllWarningsValidator) Description(ctx context.Context) string { + var descriptions []string + + for _, subValidator := range v.validators { + descriptions = append(descriptions, subValidator.Description(ctx)) + } + + return fmt.Sprintf("Value must satisfy at least one of the validations: %s", strings.Join(descriptions, " + ")) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (v anyWithAllWarningsValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +// ValidateEphemeralResource performs the validation. +func (v anyWithAllWarningsValidator) ValidateEphemeralResource(ctx context.Context, req ephemeral.ValidateConfigRequest, resp *ephemeral.ValidateConfigResponse) { + anyValid := false + + for _, subValidator := range v.validators { + validateResp := &ephemeral.ValidateConfigResponse{} + + subValidator.ValidateEphemeralResource(ctx, req, validateResp) + + if !validateResp.Diagnostics.HasError() { + anyValid = true + } + + resp.Diagnostics.Append(validateResp.Diagnostics...) + } + + if anyValid { + resp.Diagnostics = resp.Diagnostics.Warnings() + } +} diff --git a/ephemeralvalidator/any_with_all_warnings_example_test.go b/ephemeralvalidator/any_with_all_warnings_example_test.go new file mode 100644 index 00000000..83c05933 --- /dev/null +++ b/ephemeralvalidator/any_with_all_warnings_example_test.go @@ -0,0 +1,17 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ephemeralvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + + "github.com/hashicorp/terraform-plugin-framework-validators/ephemeralvalidator" +) + +func ExampleAnyWithAllWarnings() { + // Used inside a ephemeral.EphemeralResource type ConfigValidators method + _ = []ephemeral.ConfigValidator{ + ephemeralvalidator.AnyWithAllWarnings( /* ... */ ), + } +} diff --git a/ephemeralvalidator/any_with_all_warnings_test.go b/ephemeralvalidator/any_with_all_warnings_test.go new file mode 100644 index 00000000..c57c7547 --- /dev/null +++ b/ephemeralvalidator/any_with_all_warnings_test.go @@ -0,0 +1,216 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ephemeralvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/ephemeral/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-go/tftypes" + + "github.com/hashicorp/terraform-plugin-framework-validators/ephemeralvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/internal/testvalidator" +) + +func TestAnyWithAllWarningsValidatorValidateEphemeralResource(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + validators []ephemeral.ConfigValidator + req ephemeral.ValidateConfigRequest + expected *ephemeral.ValidateConfigResponse + }{ + "valid": { + validators: []ephemeral.ConfigValidator{ + ephemeralvalidator.ExactlyOneOf( + path.MatchRoot("test1"), + path.MatchRoot("test2"), + ), + ephemeralvalidator.ExactlyOneOf( + path.MatchRoot("test3"), + path.MatchRoot("test4"), + ), + }, + req: ephemeral.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ + Optional: true, + }, + "test2": schema.StringAttribute{ + Optional: true, + }, + "test3": schema.StringAttribute{ + Optional: true, + }, + "test4": schema.StringAttribute{ + Optional: true, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "test3": tftypes.String, + "test4": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, nil), + "test2": tftypes.NewValue(tftypes.String, nil), + "test3": tftypes.NewValue(tftypes.String, "test-value"), + "test4": tftypes.NewValue(tftypes.String, nil), + }, + ), + }, + }, + expected: &ephemeral.ValidateConfigResponse{}, + }, + "valid with warning": { + validators: []ephemeral.ConfigValidator{ + ephemeralvalidator.All( + ephemeralvalidator.ExactlyOneOf( + path.MatchRoot("test1"), + path.MatchRoot("test2"), + ), + testvalidator.WarningEphemeralResource("failing warning summary", "failing warning details"), + ), + ephemeralvalidator.All( + ephemeralvalidator.ExactlyOneOf( + path.MatchRoot("test3"), + path.MatchRoot("test4"), + ), + testvalidator.WarningEphemeralResource("passing warning summary", "passing warning details"), + ), + }, + req: ephemeral.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ + Optional: true, + }, + "test2": schema.StringAttribute{ + Optional: true, + }, + "test3": schema.StringAttribute{ + Optional: true, + }, + "test4": schema.StringAttribute{ + Optional: true, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "test3": tftypes.String, + "test4": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, nil), + "test2": tftypes.NewValue(tftypes.String, nil), + "test3": tftypes.NewValue(tftypes.String, "test-value"), + "test4": tftypes.NewValue(tftypes.String, nil), + }, + ), + }, + }, + expected: &ephemeral.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewWarningDiagnostic("failing warning summary", "failing warning details"), + diag.NewWarningDiagnostic("passing warning summary", "passing warning details"), + }, + }, + }, + "invalid": { + validators: []ephemeral.ConfigValidator{ + ephemeralvalidator.ExactlyOneOf( + path.MatchRoot("test1"), + path.MatchRoot("test2"), + ), + ephemeralvalidator.ExactlyOneOf( + path.MatchRoot("test3"), + path.MatchRoot("test4"), + ), + }, + req: ephemeral.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ + Optional: true, + }, + "test2": schema.StringAttribute{ + Optional: true, + }, + "test3": schema.StringAttribute{ + Optional: true, + }, + "test4": schema.StringAttribute{ + Optional: true, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "test3": tftypes.String, + "test4": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, nil), + "test2": tftypes.NewValue(tftypes.String, nil), + "test3": tftypes.NewValue(tftypes.String, nil), + "test4": tftypes.NewValue(tftypes.String, nil), + }, + ), + }, + }, + expected: &ephemeral.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Missing Attribute Configuration", + "Exactly one of these attributes must be configured: [test1,test2]", + ), + diag.NewErrorDiagnostic( + "Missing Attribute Configuration", + "Exactly one of these attributes must be configured: [test3,test4]", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &ephemeral.ValidateConfigResponse{} + + ephemeralvalidator.AnyWithAllWarnings(testCase.validators...).ValidateEphemeralResource(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/ephemeralvalidator/at_least_one_of.go b/ephemeralvalidator/at_least_one_of.go new file mode 100644 index 00000000..8be3bc9a --- /dev/null +++ b/ephemeralvalidator/at_least_one_of.go @@ -0,0 +1,18 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ephemeralvalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/configvalidator" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/path" +) + +// AtLeastOneOf checks that a set of path.Expression has at least one non-null +// or unknown value. +func AtLeastOneOf(expressions ...path.Expression) ephemeral.ConfigValidator { + return &configvalidator.AtLeastOneOfValidator{ + PathExpressions: expressions, + } +} diff --git a/ephemeralvalidator/at_least_one_of_example_test.go b/ephemeralvalidator/at_least_one_of_example_test.go new file mode 100644 index 00000000..d3eeb4f4 --- /dev/null +++ b/ephemeralvalidator/at_least_one_of_example_test.go @@ -0,0 +1,22 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ephemeralvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/ephemeralvalidator" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/path" +) + +func ExampleAtLeastOneOf() { + // Used inside a ephemeral.EphemeralResource type ConfigValidators method + _ = []ephemeral.ConfigValidator{ + // Validate at least one of the schema defined attributes named attr1 + // and attr2 has a known, non-null value. + ephemeralvalidator.AtLeastOneOf( + path.MatchRoot("attr1"), + path.MatchRoot("attr2"), + ), + } +} diff --git a/ephemeralvalidator/at_least_one_of_test.go b/ephemeralvalidator/at_least_one_of_test.go new file mode 100644 index 00000000..1c40aa7c --- /dev/null +++ b/ephemeralvalidator/at_least_one_of_test.go @@ -0,0 +1,123 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ephemeralvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework-validators/ephemeralvalidator" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/ephemeral/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestAtLeastOneOf(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + pathExpressions path.Expressions + req ephemeral.ValidateConfigRequest + expected *ephemeral.ValidateConfigResponse + }{ + "no-diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + req: ephemeral.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ + Optional: true, + }, + "other": schema.StringAttribute{ + Optional: true, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &ephemeral.ValidateConfigResponse{}, + }, + "diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + req: ephemeral.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ + Optional: true, + }, + "test2": schema.StringAttribute{ + Optional: true, + }, + "other": schema.StringAttribute{ + Optional: true, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, nil), + "test2": tftypes.NewValue(tftypes.String, nil), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &ephemeral.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Missing Attribute Configuration", + "At least one of these attributes must be configured: [test1,test2]", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + validator := ephemeralvalidator.AtLeastOneOf(testCase.pathExpressions...) + got := &ephemeral.ValidateConfigResponse{} + + validator.ValidateEphemeralResource(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/ephemeralvalidator/conflicting.go b/ephemeralvalidator/conflicting.go new file mode 100644 index 00000000..8ac8eac9 --- /dev/null +++ b/ephemeralvalidator/conflicting.go @@ -0,0 +1,18 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ephemeralvalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/configvalidator" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/path" +) + +// Conflicting checks that a set of path.Expression, are not configured +// simultaneously. +func Conflicting(expressions ...path.Expression) ephemeral.ConfigValidator { + return &configvalidator.ConflictingValidator{ + PathExpressions: expressions, + } +} diff --git a/ephemeralvalidator/conflicting_example_test.go b/ephemeralvalidator/conflicting_example_test.go new file mode 100644 index 00000000..bb1c2ca8 --- /dev/null +++ b/ephemeralvalidator/conflicting_example_test.go @@ -0,0 +1,22 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ephemeralvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/ephemeralvalidator" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/path" +) + +func ExampleConflicting() { + // Used inside a ephemeral.EphemeralResource type ConfigValidators method + _ = []ephemeral.ConfigValidator{ + // Validate that schema defined attributes named attr1 and attr2 are not + // both configured with known, non-null values. + ephemeralvalidator.Conflicting( + path.MatchRoot("attr1"), + path.MatchRoot("attr2"), + ), + } +} diff --git a/ephemeralvalidator/conflicting_test.go b/ephemeralvalidator/conflicting_test.go new file mode 100644 index 00000000..b8044b18 --- /dev/null +++ b/ephemeralvalidator/conflicting_test.go @@ -0,0 +1,124 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ephemeralvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework-validators/ephemeralvalidator" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/ephemeral/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestConflicting(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + pathExpressions path.Expressions + req ephemeral.ValidateConfigRequest + expected *ephemeral.ValidateConfigResponse + }{ + "no-diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + req: ephemeral.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ + Optional: true, + }, + "other": schema.StringAttribute{ + Optional: true, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &ephemeral.ValidateConfigResponse{}, + }, + "diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + req: ephemeral.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ + Optional: true, + }, + "test2": schema.StringAttribute{ + Optional: true, + }, + "other": schema.StringAttribute{ + Optional: true, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, "test-value"), + "test2": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &ephemeral.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test1"), + "Invalid Attribute Combination", + "These attributes cannot be configured together: [test1,test2]", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + validator := ephemeralvalidator.Conflicting(testCase.pathExpressions...) + got := &ephemeral.ValidateConfigResponse{} + + validator.ValidateEphemeralResource(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/ephemeralvalidator/doc.go b/ephemeralvalidator/doc.go new file mode 100644 index 00000000..52fd596b --- /dev/null +++ b/ephemeralvalidator/doc.go @@ -0,0 +1,13 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +// Package ephemeralvalidator provides validators to express relationships +// between multiple attributes of an ephemeral resource. For example, checking that +// multiple attributes are not configured at the same time. +// +// These validators are implemented outside the schema, which may be easier to +// implement in provider code generation situations or suit provider code +// preferences differently than those in the schemavalidator package. Those +// validators start on a starting attribute, where relationships can be +// expressed as absolute paths to others or relative to the starting attribute. +package ephemeralvalidator diff --git a/ephemeralvalidator/exactly_one_of.go b/ephemeralvalidator/exactly_one_of.go new file mode 100644 index 00000000..dfe2a8f8 --- /dev/null +++ b/ephemeralvalidator/exactly_one_of.go @@ -0,0 +1,18 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ephemeralvalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/configvalidator" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/path" +) + +// ExactlyOneOf checks that a set of path.Expression does not have more than +// one known value. +func ExactlyOneOf(expressions ...path.Expression) ephemeral.ConfigValidator { + return &configvalidator.ExactlyOneOfValidator{ + PathExpressions: expressions, + } +} diff --git a/ephemeralvalidator/exactly_one_of_example_test.go b/ephemeralvalidator/exactly_one_of_example_test.go new file mode 100644 index 00000000..7581f183 --- /dev/null +++ b/ephemeralvalidator/exactly_one_of_example_test.go @@ -0,0 +1,22 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ephemeralvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/ephemeralvalidator" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/path" +) + +func ExampleExactlyOneOf() { + // Used inside a ephemeral.EphemeralResource type ConfigValidators method + _ = []ephemeral.ConfigValidator{ + // Validate only one of the schema defined attributes named attr1 + // and attr2 has a known, non-null value. + ephemeralvalidator.ExactlyOneOf( + path.MatchRoot("attr1"), + path.MatchRoot("attr2"), + ), + } +} diff --git a/ephemeralvalidator/exactly_one_of_test.go b/ephemeralvalidator/exactly_one_of_test.go new file mode 100644 index 00000000..a205ec1f --- /dev/null +++ b/ephemeralvalidator/exactly_one_of_test.go @@ -0,0 +1,124 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ephemeralvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework-validators/ephemeralvalidator" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/ephemeral/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestExactlyOneOf(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + pathExpressions path.Expressions + req ephemeral.ValidateConfigRequest + expected *ephemeral.ValidateConfigResponse + }{ + "no-diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + req: ephemeral.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ + Optional: true, + }, + "other": schema.StringAttribute{ + Optional: true, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &ephemeral.ValidateConfigResponse{}, + }, + "diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + req: ephemeral.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ + Optional: true, + }, + "test2": schema.StringAttribute{ + Optional: true, + }, + "other": schema.StringAttribute{ + Optional: true, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, "test-value"), + "test2": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &ephemeral.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test1"), + "Invalid Attribute Combination", + "Exactly one of these attributes must be configured: [test1,test2]", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + validator := ephemeralvalidator.ExactlyOneOf(testCase.pathExpressions...) + got := &ephemeral.ValidateConfigResponse{} + + validator.ValidateEphemeralResource(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/ephemeralvalidator/required_together.go b/ephemeralvalidator/required_together.go new file mode 100644 index 00000000..565f4c0c --- /dev/null +++ b/ephemeralvalidator/required_together.go @@ -0,0 +1,18 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ephemeralvalidator + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/internal/configvalidator" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/path" +) + +// RequiredTogether checks that a set of path.Expression either has all known +// or all null values. +func RequiredTogether(expressions ...path.Expression) ephemeral.ConfigValidator { + return &configvalidator.RequiredTogetherValidator{ + PathExpressions: expressions, + } +} diff --git a/ephemeralvalidator/required_together_example_test.go b/ephemeralvalidator/required_together_example_test.go new file mode 100644 index 00000000..906ec1e4 --- /dev/null +++ b/ephemeralvalidator/required_together_example_test.go @@ -0,0 +1,22 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ephemeralvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/ephemeralvalidator" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/path" +) + +func ExampleRequiredTogether() { + // Used inside a ephemeral.EphemeralResource type ConfigValidators method + _ = []ephemeral.ConfigValidator{ + // Validate the schema defined attributes named attr1 and attr2 are either + // both null or both known values. + ephemeralvalidator.RequiredTogether( + path.MatchRoot("attr1"), + path.MatchRoot("attr2"), + ), + } +} diff --git a/ephemeralvalidator/required_together_test.go b/ephemeralvalidator/required_together_test.go new file mode 100644 index 00000000..9dd514d2 --- /dev/null +++ b/ephemeralvalidator/required_together_test.go @@ -0,0 +1,124 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ephemeralvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework-validators/ephemeralvalidator" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/ephemeral/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestRequiredTogether(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + pathExpressions path.Expressions + req ephemeral.ValidateConfigRequest + expected *ephemeral.ValidateConfigResponse + }{ + "no-diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + req: ephemeral.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ + Optional: true, + }, + "other": schema.StringAttribute{ + Optional: true, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &ephemeral.ValidateConfigResponse{}, + }, + "diagnostics": { + pathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + req: ephemeral.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ + Optional: true, + }, + "test2": schema.StringAttribute{ + Optional: true, + }, + "other": schema.StringAttribute{ + Optional: true, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, "test-value"), + "test2": tftypes.NewValue(tftypes.String, nil), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &ephemeral.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test1"), + "Invalid Attribute Combination", + "These attributes must be configured together: [test1,test2]", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + validator := ephemeralvalidator.RequiredTogether(testCase.pathExpressions...) + got := &ephemeral.ValidateConfigResponse{} + + validator.ValidateEphemeralResource(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/go.mod b/go.mod index 1ebd9191..75219a0c 100644 --- a/go.mod +++ b/go.mod @@ -6,8 +6,8 @@ toolchain go1.22.7 require ( github.com/google/go-cmp v0.6.0 - github.com/hashicorp/terraform-plugin-framework v1.12.0 - github.com/hashicorp/terraform-plugin-go v0.24.0 + github.com/hashicorp/terraform-plugin-framework v1.13.0 + github.com/hashicorp/terraform-plugin-go v0.25.0 ) require ( @@ -15,9 +15,9 @@ require ( github.com/hashicorp/go-hclog v1.5.0 // indirect github.com/hashicorp/terraform-plugin-log v0.9.0 // indirect github.com/mattn/go-colorable v0.1.12 // indirect - github.com/mattn/go-isatty v0.0.14 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect github.com/mitchellh/go-testing-interface v1.14.1 // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect - golang.org/x/sys v0.21.0 // indirect + golang.org/x/sys v0.24.0 // indirect ) diff --git a/go.sum b/go.sum index 3521a4c7..fc181b56 100644 --- a/go.sum +++ b/go.sum @@ -7,18 +7,19 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/hashicorp/go-hclog v1.5.0 h1:bI2ocEMgcVlz55Oj1xZNBsVi900c7II+fWDyV9o+13c= github.com/hashicorp/go-hclog v1.5.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= -github.com/hashicorp/terraform-plugin-framework v1.12.0 h1:7HKaueHPaikX5/7cbC1r9d1m12iYHY+FlNZEGxQ42CQ= -github.com/hashicorp/terraform-plugin-framework v1.12.0/go.mod h1:N/IOQ2uYjW60Jp39Cp3mw7I/OpC/GfZ0385R0YibmkE= -github.com/hashicorp/terraform-plugin-go v0.24.0 h1:2WpHhginCdVhFIrWHxDEg6RBn3YaWzR2o6qUeIEat2U= -github.com/hashicorp/terraform-plugin-go v0.24.0/go.mod h1:tUQ53lAsOyYSckFGEefGC5C8BAaO0ENqzFd3bQeuYQg= +github.com/hashicorp/terraform-plugin-framework v1.13.0 h1:8OTG4+oZUfKgnfTdPTJwZ532Bh2BobF4H+yBiYJ/scw= +github.com/hashicorp/terraform-plugin-framework v1.13.0/go.mod h1:j64rwMGpgM3NYXTKuxrCnyubQb/4VKldEKlcG8cvmjU= +github.com/hashicorp/terraform-plugin-go v0.25.0 h1:oi13cx7xXA6QciMcpcFi/rwA974rdTxjqEhXJjbAyks= +github.com/hashicorp/terraform-plugin-go v0.25.0/go.mod h1:+SYagMYadJP86Kvn+TGeV+ofr/R3g4/If0O5sO96MVw= github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= github.com/hashicorp/terraform-plugin-log v0.9.0/go.mod h1:rKL8egZQ/eXSyDqzLUuwUYLVdlYeamldAHSxjUFADow= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -35,8 +36,9 @@ golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= -golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= +golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/configvalidator/at_least_one_of.go b/internal/configvalidator/at_least_one_of.go index 9d67ef4a..03a32a35 100644 --- a/internal/configvalidator/at_least_one_of.go +++ b/internal/configvalidator/at_least_one_of.go @@ -10,6 +10,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/resource" @@ -45,6 +46,10 @@ func (v AtLeastOneOfValidator) ValidateResource(ctx context.Context, req resourc resp.Diagnostics = v.Validate(ctx, req.Config) } +func (v AtLeastOneOfValidator) ValidateEphemeralResource(ctx context.Context, req ephemeral.ValidateConfigRequest, resp *ephemeral.ValidateConfigResponse) { + resp.Diagnostics = v.Validate(ctx, req.Config) +} + func (v AtLeastOneOfValidator) Validate(ctx context.Context, config tfsdk.Config) diag.Diagnostics { var configuredPaths, unknownPaths path.Paths var diags diag.Diagnostics diff --git a/internal/configvalidator/at_least_one_of_test.go b/internal/configvalidator/at_least_one_of_test.go index 9b583649..94eeb86f 100644 --- a/internal/configvalidator/at_least_one_of_test.go +++ b/internal/configvalidator/at_least_one_of_test.go @@ -10,6 +10,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/resource" @@ -934,3 +935,111 @@ func TestAtLeastOneOfValidatorValidateResource(t *testing.T) { }) } } + +func TestAtLeastOneOfValidatorValidateEphemeralResource(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + validator configvalidator.AtLeastOneOfValidator + req ephemeral.ValidateConfigRequest + expected *ephemeral.ValidateConfigResponse + }{ + "no-diagnostics": { + validator: configvalidator.AtLeastOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + }, + req: ephemeral.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ + Optional: true, + }, + "other": schema.StringAttribute{ + Optional: true, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &ephemeral.ValidateConfigResponse{}, + }, + "diagnostics": { + validator: configvalidator.AtLeastOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + }, + req: ephemeral.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ + Optional: true, + }, + "test2": schema.StringAttribute{ + Optional: true, + }, + "other": schema.StringAttribute{ + Optional: true, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, nil), + "test2": tftypes.NewValue(tftypes.String, nil), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &ephemeral.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Missing Attribute Configuration", + "At least one of these attributes must be configured: [test1,test2]", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &ephemeral.ValidateConfigResponse{} + + testCase.validator.ValidateEphemeralResource(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/internal/configvalidator/conflicting.go b/internal/configvalidator/conflicting.go index edd2abd8..38dfd5c4 100644 --- a/internal/configvalidator/conflicting.go +++ b/internal/configvalidator/conflicting.go @@ -11,6 +11,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/resource" @@ -46,6 +47,10 @@ func (v ConflictingValidator) ValidateResource(ctx context.Context, req resource resp.Diagnostics = v.Validate(ctx, req.Config) } +func (v ConflictingValidator) ValidateEphemeralResource(ctx context.Context, req ephemeral.ValidateConfigRequest, resp *ephemeral.ValidateConfigResponse) { + resp.Diagnostics = v.Validate(ctx, req.Config) +} + func (v ConflictingValidator) Validate(ctx context.Context, config tfsdk.Config) diag.Diagnostics { var configuredPaths path.Paths var diags diag.Diagnostics diff --git a/internal/configvalidator/conflicting_test.go b/internal/configvalidator/conflicting_test.go index d9b63495..1f513f27 100644 --- a/internal/configvalidator/conflicting_test.go +++ b/internal/configvalidator/conflicting_test.go @@ -10,6 +10,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/resource" @@ -941,3 +942,112 @@ func TestConflictingValidatorValidateResource(t *testing.T) { }) } } + +func TestConflictingValidatorValidateEphemeralResource(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + validator configvalidator.ConflictingValidator + req ephemeral.ValidateConfigRequest + expected *ephemeral.ValidateConfigResponse + }{ + "no-diagnostics": { + validator: configvalidator.ConflictingValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + }, + req: ephemeral.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ + Optional: true, + }, + "other": schema.StringAttribute{ + Optional: true, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &ephemeral.ValidateConfigResponse{}, + }, + "diagnostics": { + validator: configvalidator.ConflictingValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + }, + req: ephemeral.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ + Optional: true, + }, + "test2": schema.StringAttribute{ + Optional: true, + }, + "other": schema.StringAttribute{ + Optional: true, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, "test-value"), + "test2": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &ephemeral.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test1"), + "Invalid Attribute Combination", + "These attributes cannot be configured together: [test1,test2]", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &ephemeral.ValidateConfigResponse{} + + testCase.validator.ValidateEphemeralResource(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/internal/configvalidator/doc.go b/internal/configvalidator/doc.go index 789b4159..b3533ae2 100644 --- a/internal/configvalidator/doc.go +++ b/internal/configvalidator/doc.go @@ -2,6 +2,6 @@ // SPDX-License-Identifier: MPL-2.0 // Package configvalidator provides the generic configuration validator -// implementations for the exported datasourcevalidator, providervalidator, and -// resourcevalidator packages. +// implementations for the exported datasourcevalidator, providervalidator, +// resourcevalidator, and ephemeralvalidator packages. package configvalidator diff --git a/internal/configvalidator/exactly_one_of.go b/internal/configvalidator/exactly_one_of.go index b76786f8..14904d5a 100644 --- a/internal/configvalidator/exactly_one_of.go +++ b/internal/configvalidator/exactly_one_of.go @@ -11,6 +11,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/resource" @@ -46,6 +47,10 @@ func (v ExactlyOneOfValidator) ValidateResource(ctx context.Context, req resourc resp.Diagnostics = v.Validate(ctx, req.Config) } +func (v ExactlyOneOfValidator) ValidateEphemeralResource(ctx context.Context, req ephemeral.ValidateConfigRequest, resp *ephemeral.ValidateConfigResponse) { + resp.Diagnostics = v.Validate(ctx, req.Config) +} + func (v ExactlyOneOfValidator) Validate(ctx context.Context, config tfsdk.Config) diag.Diagnostics { var configuredPaths, unknownPaths path.Paths var diags diag.Diagnostics diff --git a/internal/configvalidator/exactly_one_of_test.go b/internal/configvalidator/exactly_one_of_test.go index fd85bf9b..805c2ed8 100644 --- a/internal/configvalidator/exactly_one_of_test.go +++ b/internal/configvalidator/exactly_one_of_test.go @@ -10,6 +10,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/resource" @@ -961,3 +962,112 @@ func TestExactlyOneOfValidatorValidateResource(t *testing.T) { }) } } + +func TestExactlyOneOfValidatorValidateEphemeralResource(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + validator configvalidator.ExactlyOneOfValidator + req ephemeral.ValidateConfigRequest + expected *ephemeral.ValidateConfigResponse + }{ + "no-diagnostics": { + validator: configvalidator.ExactlyOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + }, + req: ephemeral.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ + Optional: true, + }, + "other": schema.StringAttribute{ + Optional: true, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &ephemeral.ValidateConfigResponse{}, + }, + "diagnostics": { + validator: configvalidator.ExactlyOneOfValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + }, + req: ephemeral.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ + Optional: true, + }, + "test2": schema.StringAttribute{ + Optional: true, + }, + "other": schema.StringAttribute{ + Optional: true, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, "test-value"), + "test2": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &ephemeral.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test1"), + "Invalid Attribute Combination", + "Exactly one of these attributes must be configured: [test1,test2]", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &ephemeral.ValidateConfigResponse{} + + testCase.validator.ValidateEphemeralResource(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/internal/configvalidator/required_together.go b/internal/configvalidator/required_together.go index 69c46671..e694e91d 100644 --- a/internal/configvalidator/required_together.go +++ b/internal/configvalidator/required_together.go @@ -11,6 +11,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/resource" @@ -46,6 +47,10 @@ func (v RequiredTogetherValidator) ValidateResource(ctx context.Context, req res resp.Diagnostics = v.Validate(ctx, req.Config) } +func (v RequiredTogetherValidator) ValidateEphemeralResource(ctx context.Context, req ephemeral.ValidateConfigRequest, resp *ephemeral.ValidateConfigResponse) { + resp.Diagnostics = v.Validate(ctx, req.Config) +} + func (v RequiredTogetherValidator) Validate(ctx context.Context, config tfsdk.Config) diag.Diagnostics { var configuredPaths, foundPaths, unknownPaths path.Paths var diags diag.Diagnostics diff --git a/internal/configvalidator/required_together_test.go b/internal/configvalidator/required_together_test.go index 7f7a1c9f..fd7f04f7 100644 --- a/internal/configvalidator/required_together_test.go +++ b/internal/configvalidator/required_together_test.go @@ -10,6 +10,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/resource" @@ -929,3 +930,112 @@ func TestRequiredTogetherValidatorValidateResource(t *testing.T) { }) } } + +func TestRequiredTogetherValidatorValidateEphemeralResource(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + validator configvalidator.RequiredTogetherValidator + req ephemeral.ValidateConfigRequest + expected *ephemeral.ValidateConfigResponse + }{ + "no-diagnostics": { + validator: configvalidator.RequiredTogetherValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test"), + }, + }, + req: ephemeral.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ + Optional: true, + }, + "other": schema.StringAttribute{ + Optional: true, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &ephemeral.ValidateConfigResponse{}, + }, + "diagnostics": { + validator: configvalidator.RequiredTogetherValidator{ + PathExpressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + }, + req: ephemeral.ValidateConfigRequest{ + Config: tfsdk.Config{ + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test1": schema.StringAttribute{ + Optional: true, + }, + "test2": schema.StringAttribute{ + Optional: true, + }, + "other": schema.StringAttribute{ + Optional: true, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test1": tftypes.String, + "test2": tftypes.String, + "other": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test1": tftypes.NewValue(tftypes.String, "test-value"), + "test2": tftypes.NewValue(tftypes.String, nil), + "other": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + }, + expected: &ephemeral.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test1"), + "Invalid Attribute Combination", + "These attributes must be configured together: [test1,test2]", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &ephemeral.ValidateConfigResponse{} + + testCase.validator.ValidateEphemeralResource(context.Background(), testCase.req, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/internal/testvalidator/warning.go b/internal/testvalidator/warning.go index f92a14ac..f988ed99 100644 --- a/internal/testvalidator/warning.go +++ b/internal/testvalidator/warning.go @@ -7,6 +7,7 @@ import ( "context" "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/schema/validator" @@ -108,6 +109,14 @@ func WarningResource(summary string, detail string) resource.ConfigValidator { } } +// WarningEphemeralResource returns a validator which returns a warning diagnostic. +func WarningEphemeralResource(summary string, detail string) ephemeral.ConfigValidator { + return WarningValidator{ + Summary: summary, + Detail: detail, + } +} + // WarningSet returns a validator which returns a warning diagnostic. func WarningSet(summary string, detail string) validator.Set { return WarningValidator{ @@ -202,6 +211,10 @@ func (v WarningValidator) ValidateResource(ctx context.Context, request resource response.Diagnostics.AddWarning(v.Summary, v.Detail) } +func (v WarningValidator) ValidateEphemeralResource(ctx context.Context, request ephemeral.ValidateConfigRequest, response *ephemeral.ValidateConfigResponse) { + response.Diagnostics.AddWarning(v.Summary, v.Detail) +} + func (v WarningValidator) ValidateSet(ctx context.Context, request validator.SetRequest, response *validator.SetResponse) { response.Diagnostics.AddWarning(v.Summary, v.Detail) }
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: