diff --git a/internal/fwserver/server_listresource.go b/internal/fwserver/server_listresource.go new file mode 100644 index 000000000..843f17be9 --- /dev/null +++ b/internal/fwserver/server_listresource.go @@ -0,0 +1,125 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package fwserver + +import ( + "context" + "iter" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/logging" + "github.com/hashicorp/terraform-plugin-framework/list" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" +) + +// ListRequest represents a request for the provider to list instances of a +// managed resource type that satisfy a user-defined request. An instance of +// this reqeuest struct is passed as an argument to the provider's List +// function implementation. +type ListRequest struct { + // ListResource is an instance of the provider's ListResource + // implementation for a specific managed resource type. + ListResource list.ListResource + + // Config is the configuration the user supplied for listing resource + // instances. + Config tfsdk.Config + + // IncludeResource indicates whether the provider should populate the + // Resource field in the ListResult struct. + IncludeResource bool +} + +// ListResultsStream represents a streaming response to a ListRequest. An +// instance of this struct is supplied as an argument to the provider's List +// function. The provider should set a Results iterator function that pushes +// zero or more results of type ListResult. +// +// For convenience, a provider implementation may choose to convert a slice of +// results into an iterator using [slices.Values]. +// +// [slices.Values]: https://pkg.go.dev/slices#Values +type ListResourceStream struct { + // Results is a function that emits ListResult values via its push + // function argument. + Results iter.Seq[ListResult] +} + +// ListResult represents a listed managed resource instance. +type ListResult struct { + // Identity is the identity of the managed resource instance. A nil value + // will raise will raise a diagnostic. + Identity *tfsdk.ResourceIdentity + + // Resource is the provider's representation of the attributes of the + // listed managed resource instance. + // + // If ListRequest.IncludeResource is true, a nil value will raise + // a warning diagnostic. + Resource *tfsdk.Resource + + // DisplayName is a provider-defined human-readable description of the + // listed managed resource instance, intended for CLI and browser UIs. + DisplayName string + + // Diagnostics report errors or warnings related to the listed managed + // resource instance. An empty slice indicates a successful operation with + // no warnings or errors generated. + Diagnostics diag.Diagnostics +} + +// ListResource implements the framework server ListResource RPC. +func (s *Server) ListResource(ctx context.Context, fwReq *ListRequest, fwStream *ListResourceStream) error { + listResource := fwReq.ListResource + + req := list.ListRequest{ + Config: fwReq.Config, + IncludeResource: fwReq.IncludeResource, + } + + stream := &list.ListResultsStream{} + + logging.FrameworkTrace(ctx, "Calling provider defined ListResource") + listResource.List(ctx, req, stream) + logging.FrameworkTrace(ctx, "Called provider defined ListResource") + + if stream.Results == nil { + // If the provider returned a nil results stream, we return an empty stream. + stream.Results = list.NoListResults + } + + fwStream.Results = processListResults(req, stream.Results) + return nil +} + +func processListResults(req list.ListRequest, stream iter.Seq[list.ListResult]) iter.Seq[ListResult] { + return func(push func(ListResult) bool) { + for result := range stream { + if !push(processListResult(req, result)) { + return + } + } + } +} + +// processListResult validates the content of a list.ListResult and returns a +// ListResult +func processListResult(req list.ListRequest, result list.ListResult) ListResult { + if result.Identity == nil { + return ListResult{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic("Incomplete List Result", "ListResult.Identity is nil."), + }, + } + } + + if req.IncludeResource && result.Resource == nil { + result.Diagnostics.AddWarning( + "Incomplete List Result", + "ListRequest.IncludeResource is true and ListResult.Resource is nil.", + ) + } + + return ListResult(result) +} diff --git a/internal/fwserver/server_listresource_test.go b/internal/fwserver/server_listresource_test.go new file mode 100644 index 000000000..14ad8b13d --- /dev/null +++ b/internal/fwserver/server_listresource_test.go @@ -0,0 +1,184 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package fwserver_test + +import ( + "context" + "slices" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" + "github.com/hashicorp/terraform-plugin-framework/internal/testing/testprovider" + "github.com/hashicorp/terraform-plugin-framework/list" + "github.com/hashicorp/terraform-plugin-framework/resource/identityschema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestServerListResource(t *testing.T) { + t.Parallel() + + testSchema := schema.Schema{ + Attributes: map[string]schema.Attribute{ + "test_computed": schema.StringAttribute{ + Computed: true, + }, + "test_required": schema.StringAttribute{ + Required: true, + }, + }, + } + + testType := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_attribute": tftypes.String, + }, + } + + testResourceValue1 := tftypes.NewValue(testType, map[string]tftypes.Value{ + "test_attribute": tftypes.NewValue(tftypes.String, "test-value-1"), + }) + + testResourceValue2 := tftypes.NewValue(testType, map[string]tftypes.Value{ + "test_attribute": tftypes.NewValue(tftypes.String, "test-value-2"), + }) + + testIdentitySchema := identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "test_id": identityschema.StringAttribute{ + RequiredForImport: true, + }, + }, + } + + testIdentityType := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_id": tftypes.String, + }, + } + + testIdentityValue1 := tftypes.NewValue(testIdentityType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "new-id-123"), + }) + + testIdentityValue2 := tftypes.NewValue(testIdentityType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "new-id-456"), + }) + + // nilIdentityValue := tftypes.NewValue(testIdentityType, nil) + + testCases := map[string]struct { + server *fwserver.Server + request *fwserver.ListRequest + expectedStreamEvents []fwserver.ListResult + }{ + "success-with-zero-results": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.ListRequest{ + ListResource: &testprovider.ListResource{ + ListMethod: func(ctx context.Context, req list.ListRequest, resp *list.ListResultsStream) { // TODO + resp.Results = list.NoListResults + }, + }, + }, + expectedStreamEvents: []fwserver.ListResult{}, + }, + "success-with-nil-results": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.ListRequest{ + ListResource: &testprovider.ListResource{ + ListMethod: func(ctx context.Context, req list.ListRequest, resp *list.ListResultsStream) { // TODO + // Do nothing, so that resp.Results is nil + }, + }, + }, + expectedStreamEvents: []fwserver.ListResult{}, + }, + + "success-with-multiple-results": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.ListRequest{ + ListResource: &testprovider.ListResource{ + ListMethod: func(ctx context.Context, req list.ListRequest, resp *list.ListResultsStream) { // TODO + resp.Results = slices.Values([]list.ListResult{ + { + Identity: &tfsdk.ResourceIdentity{ + Schema: testIdentitySchema, + Raw: testIdentityValue1, + }, + Resource: &tfsdk.Resource{ + Schema: testSchema, + Raw: testResourceValue1, + }, + DisplayName: "Test Resource 1", + Diagnostics: diag.Diagnostics{}, + }, + { + Identity: &tfsdk.ResourceIdentity{ + Schema: testIdentitySchema, + Raw: testIdentityValue2, + }, + Resource: &tfsdk.Resource{ + Schema: testSchema, + Raw: testResourceValue2, + }, + DisplayName: "Test Resource 2", + Diagnostics: diag.Diagnostics{}, + }, + }) + }, + }, + }, + expectedStreamEvents: []fwserver.ListResult{ + { + Identity: &tfsdk.ResourceIdentity{ + Schema: testIdentitySchema, + Raw: testIdentityValue1, + }, + Resource: &tfsdk.Resource{ + Schema: testSchema, + Raw: testResourceValue1, + }, + DisplayName: "Test Resource 1", + Diagnostics: diag.Diagnostics{}, + }, + { + Identity: &tfsdk.ResourceIdentity{ + Schema: testIdentitySchema, + Raw: testIdentityValue2, + }, + Resource: &tfsdk.Resource{ + Schema: testSchema, + Raw: testResourceValue2, + }, + DisplayName: "Test Resource 2", + Diagnostics: diag.Diagnostics{}, + }, + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + response := &fwserver.ListResourceStream{} + testCase.server.ListResource(context.Background(), testCase.request, response) + + events := slices.AppendSeq([]fwserver.ListResult{}, response.Results) + if diff := cmp.Diff(events, testCase.expectedStreamEvents); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/list/list_resource.go b/list/list_resource.go index def1abfd3..da7e757fe 100644 --- a/list/list_resource.go +++ b/list/list_resource.go @@ -85,10 +85,10 @@ type ListResourceWithValidateConfig interface { ValidateListResourceConfig(context.Context, ValidateConfigRequest, *ValidateConfigResponse) } -// ListRequest represents a request for the provider to list instances -// of a managed resource type that satisfy a user-defined request. An instance -// of this reqeuest struct is passed as an argument to the provider's -// ListResource function implementation. +// ListRequest represents a request for the provider to list instances of a +// managed resource type that satisfy a user-defined request. An instance of +// this reqeuest struct is passed as an argument to the provider's List +// function implementation. type ListRequest struct { // Config is the configuration the user supplied for listing resource // instances. @@ -99,21 +99,22 @@ type ListRequest struct { IncludeResource bool } -// ListResultsStream represents a streaming response to a ListRequest. -// An instance of this struct is supplied as an argument to the provider's -// ListResource function implementation function. The provider should set a Results -// iterator function that yields zero or more results of type ListResult. +// ListResultsStream represents a streaming response to a ListRequest. An +// instance of this struct is supplied as an argument to the provider's +// ListResource function. The provider should set a Results iterator function +// that pushes zero or more results of type ListResult. // // For convenience, a provider implementation may choose to convert a slice of // results into an iterator using [slices.Values]. -// -// [slices.Values]: https://pkg.go.dev/slices#Values type ListResultsStream struct { - // Results is a function that emits ListResult values via its yield + // Results is a function that emits ListResult values via its push // function argument. Results iter.Seq[ListResult] } +// NoListResults is an iterator that pushes zero results. +var NoListResults = func(func(ListResult) bool) {} + // ListResult represents a listed managed resource instance. type ListResult struct { // Identity is the identity of the managed resource instance. diff --git a/list/schema/schema.go b/list/schema/schema.go index 759557c16..8ff73ecf7 100644 --- a/list/schema/schema.go +++ b/list/schema/schema.go @@ -17,9 +17,8 @@ import ( // Schema must satify the fwschema.Schema interface. var _ fwschema.Schema = Schema{} -// Schema defines the structure and value types of resource data. This type -// is used as the resource.SchemaResponse type Schema field, which is -// implemented by the resource.DataSource type Schema method. +// Schema defines the structure and value types of a list block. This is returned as a ListResourceSchemas map value by +// the GetProviderSchemas RPC. type Schema struct { // Attributes is the mapping of underlying attribute names to attribute // definitions. @@ -59,16 +58,6 @@ type Schema struct { // will be removed in the next major version of the provider." // DeprecationMessage string - - // Version indicates the current version of the resource schema. Resource - // schema versioning enables state upgrades in conjunction with the - // [resource.ResourceWithStateUpgrades] interface. Versioning is only - // required if there is a breaking change involving existing state data, - // such as changing an attribute or block type in a manner that is - // incompatible with the Terraform type. - // - // Versions are conventionally only incremented by one each release. - Version int64 } // ApplyTerraform5AttributePathStep applies the given AttributePathStep to the @@ -116,9 +105,9 @@ func (s Schema) GetMarkdownDescription() string { return s.MarkdownDescription } -// GetVersion returns the Version field value. +// GetVersion always returns 0 as list resource schemas cannot be versioned. func (s Schema) GetVersion() int64 { - return s.Version + return 0 } // Type returns the framework type of the schema. @@ -136,17 +125,10 @@ func (s Schema) TypeAtTerraformPath(ctx context.Context, p *tftypes.AttributePat return fwschema.SchemaTypeAtTerraformPath(ctx, s, p) } -// Validate verifies that the schema is not using a reserved field name for a top-level attribute. -// -// Deprecated: Use the ValidateImplementation method instead. -func (s Schema) Validate() diag.Diagnostics { - return s.ValidateImplementation(context.Background()) -} - // ValidateImplementation contains logic for validating the provider-defined // implementation of the schema and underlying attributes and blocks to prevent // unexpected errors or panics. This logic runs during the -// ValidateResourceConfig RPC, or via provider-defined unit testing, and should +// ValidateListResourceConfig RPC, or via provider-defined unit testing, and should // never include false positives. func (s Schema) ValidateImplementation(ctx context.Context) diag.Diagnostics { var diags diag.Diagnostics diff --git a/list/schema/schema_test.go b/list/schema/schema_test.go index 9e7056a2a..b556ac7df 100644 --- a/list/schema/schema_test.go +++ b/list/schema/schema_test.go @@ -538,15 +538,6 @@ func TestSchemaGetVersion(t *testing.T) { }, expected: 0, }, - "version": { - schema: schema.Schema{ - Attributes: map[string]schema.Attribute{ - "testattr": schema.StringAttribute{}, - }, - Version: 1, - }, - expected: 1, - }, } for name, testCase := range testCases { @@ -806,47 +797,6 @@ func TestSchemaTypeAtTerraformPath(t *testing.T) { } } -func TestSchemaValidate(t *testing.T) { - t.Parallel() - - testCases := map[string]struct { - schema schema.Schema - expectedDiags diag.Diagnostics - }{ - "empty-schema": { - schema: schema.Schema{}, - }, - "validate-implementation-error": { - schema: schema.Schema{ - Attributes: map[string]schema.Attribute{ - "depends_on": schema.StringAttribute{}, - }, - }, - expectedDiags: diag.Diagnostics{ - diag.NewErrorDiagnostic( - "Reserved Root Attribute/Block Name", - "When validating the resource or data source schema, an implementation issue was found. "+ - "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ - "\"depends_on\" is a reserved root attribute/block name. "+ - "This is to prevent practitioners from needing special Terraform configuration syntax.", - ), - }, - }, - } - - for name, testCase := range testCases { - t.Run(name, func(t *testing.T) { - t.Parallel() - - diags := testCase.schema.Validate() - - if diff := cmp.Diff(diags, testCase.expectedDiags); diff != "" { - t.Errorf("Unexpected diagnostics (+wanted, -got): %s", diff) - } - }) - } -} - func TestSchemaValidateImplementation(t *testing.T) { t.Parallel() diff --git a/list/schema/string_attribute_test.go b/list/schema/string_attribute_test.go index c1dffa2f2..77df7c025 100644 --- a/list/schema/string_attribute_test.go +++ b/list/schema/string_attribute_test.go @@ -4,7 +4,6 @@ package schema_test import ( - "context" "fmt" "strings" "testing" @@ -13,15 +12,10 @@ import ( "github.com/hashicorp/terraform-plugin-go/tftypes" "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testschema" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" - "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/resource/schema" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/defaults" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/list/schema" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" ) @@ -260,12 +254,6 @@ func TestStringAttributeIsComputed(t *testing.T) { attribute: schema.StringAttribute{}, expected: false, }, - "computed": { - attribute: schema.StringAttribute{ - Computed: true, - }, - expected: true, - }, } for name, testCase := range testCases { @@ -356,12 +344,6 @@ func TestStringAttributeIsSensitive(t *testing.T) { attribute: schema.StringAttribute{}, expected: false, }, - "sensitive": { - attribute: schema.StringAttribute{ - Sensitive: true, - }, - expected: true, - }, } for name, testCase := range testCases { @@ -388,12 +370,6 @@ func TestStringAttributeIsWriteOnly(t *testing.T) { attribute: schema.StringAttribute{}, expected: false, }, - "writeOnly": { - attribute: schema.StringAttribute{ - WriteOnly: true, - }, - expected: true, - }, } for name, testCase := range testCases { @@ -409,83 +385,6 @@ func TestStringAttributeIsWriteOnly(t *testing.T) { } } -func TestStringAttributeStringDefaultValue(t *testing.T) { - t.Parallel() - - opt := cmp.Comparer(func(x, y defaults.String) bool { - ctx := context.Background() - req := defaults.StringRequest{} - - xResp := defaults.StringResponse{} - x.DefaultString(ctx, req, &xResp) - - yResp := defaults.StringResponse{} - y.DefaultString(ctx, req, &yResp) - - return xResp.PlanValue.Equal(yResp.PlanValue) - }) - - testCases := map[string]struct { - attribute schema.StringAttribute - expected defaults.String - }{ - "no-default": { - attribute: schema.StringAttribute{}, - expected: nil, - }, - "default": { - attribute: schema.StringAttribute{ - Default: stringdefault.StaticString("test-value"), - }, - expected: stringdefault.StaticString("test-value"), - }, - } - - for name, testCase := range testCases { - t.Run(name, func(t *testing.T) { - t.Parallel() - - got := testCase.attribute.StringDefaultValue() - - if diff := cmp.Diff(got, testCase.expected, opt); diff != "" { - t.Errorf("unexpected difference: %s", diff) - } - }) - } -} - -func TestStringAttributeStringPlanModifiers(t *testing.T) { - t.Parallel() - - testCases := map[string]struct { - attribute schema.StringAttribute - expected []planmodifier.String - }{ - "no-planmodifiers": { - attribute: schema.StringAttribute{}, - expected: nil, - }, - "planmodifiers": { - attribute: schema.StringAttribute{ - PlanModifiers: []planmodifier.String{}, - }, - expected: []planmodifier.String{}, - }, - } - - for name, testCase := range testCases { - t.Run(name, func(t *testing.T) { - t.Parallel() - - got := testCase.attribute.StringPlanModifiers() - - if diff := cmp.Diff(got, testCase.expected); diff != "" { - t.Errorf("unexpected difference: %s", diff) - } - }) - } -} - func TestStringAttributeStringValidators(t *testing.T) { t.Parallel() @@ -518,69 +417,6 @@ func TestStringAttributeStringValidators(t *testing.T) { } } -func TestStringAttributeValidateImplementation(t *testing.T) { - t.Parallel() - - testCases := map[string]struct { - attribute schema.StringAttribute - request fwschema.ValidateImplementationRequest - expected *fwschema.ValidateImplementationResponse - }{ - "computed": { - attribute: schema.StringAttribute{ - Computed: true, - }, - request: fwschema.ValidateImplementationRequest{ - Name: "test", - Path: path.Root("test"), - }, - expected: &fwschema.ValidateImplementationResponse{}, - }, - "default-without-computed": { - attribute: schema.StringAttribute{ - Default: stringdefault.StaticString("test"), - }, - request: fwschema.ValidateImplementationRequest{ - Name: "test", - Path: path.Root("test"), - }, - expected: &fwschema.ValidateImplementationResponse{ - Diagnostics: diag.Diagnostics{ - diag.NewErrorDiagnostic( - "Schema Using Attribute Default For Non-Computed Attribute", - "Attribute \"test\" must be computed when using default. "+ - "This is an issue with the provider and should be reported to the provider developers.", - ), - }, - }, - }, - "default-with-computed": { - attribute: schema.StringAttribute{ - Computed: true, - Default: stringdefault.StaticString("test"), - }, - request: fwschema.ValidateImplementationRequest{ - Name: "test", - Path: path.Root("test"), - }, - expected: &fwschema.ValidateImplementationResponse{}, - }, - } - - for name, testCase := range testCases { - t.Run(name, func(t *testing.T) { - t.Parallel() - - got := &fwschema.ValidateImplementationResponse{} - testCase.attribute.ValidateImplementation(context.Background(), testCase.request, got) - - if diff := cmp.Diff(got, testCase.expected); diff != "" { - t.Errorf("unexpected difference: %s", diff) - } - }) - } -} - func TestStringAttributeIsRequiredForImport(t *testing.T) { t.Parallel()