diff --git a/go.mod b/go.mod index e665cd8d7..9ad4347fc 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ toolchain go1.23.7 require ( github.com/google/go-cmp v0.7.0 - github.com/hashicorp/terraform-plugin-go v0.28.0 + github.com/hashicorp/terraform-plugin-go v0.28.1-0.20250616135123-a19df43120ea github.com/hashicorp/terraform-plugin-log v0.9.0 ) @@ -16,7 +16,7 @@ require ( github.com/hashicorp/go-hclog v1.5.0 // indirect github.com/hashicorp/go-plugin v1.6.3 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect - github.com/hashicorp/terraform-registry-address v0.2.5 // indirect + github.com/hashicorp/terraform-registry-address v0.3.0 // indirect github.com/hashicorp/terraform-svchost v0.1.1 // indirect github.com/hashicorp/yamux v0.1.1 // indirect github.com/mattn/go-colorable v0.1.12 // indirect @@ -25,10 +25,10 @@ require ( github.com/oklog/run v1.0.0 // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect - golang.org/x/net v0.39.0 // indirect - golang.org/x/sys v0.32.0 // indirect - golang.org/x/text v0.24.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect - google.golang.org/grpc v1.72.1 // indirect + golang.org/x/net v0.41.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.26.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect + google.golang.org/grpc v1.73.0 // indirect google.golang.org/protobuf v1.36.6 // indirect ) diff --git a/go.sum b/go.sum index 1020ed5b4..33da5983d 100644 --- a/go.sum +++ b/go.sum @@ -21,12 +21,12 @@ github.com/hashicorp/go-plugin v1.6.3 h1:xgHB+ZUSYeuJi96WtxEjzi23uh7YQpznjGh0U0U github.com/hashicorp/go-plugin v1.6.3/go.mod h1:MRobyh+Wc/nYy1V4KAXUiYfzxoYhs7V1mlH1Z7iY2h0= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/terraform-plugin-go v0.28.0 h1:zJmu2UDwhVN0J+J20RE5huiF3XXlTYVIleaevHZgKPA= -github.com/hashicorp/terraform-plugin-go v0.28.0/go.mod h1:FDa2Bb3uumkTGSkTFpWSOwWJDwA7bf3vdP3ltLDTH6o= +github.com/hashicorp/terraform-plugin-go v0.28.1-0.20250616135123-a19df43120ea h1:U9EAAeQtszGlR7mDS7rY77B/a4/XiMDB8HfAtqLAuAQ= +github.com/hashicorp/terraform-plugin-go v0.28.1-0.20250616135123-a19df43120ea/go.mod h1:hL//wLEfYo0YVt0TC/VLzia/ADQQto3HEm4/jX2gkdY= 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/hashicorp/terraform-registry-address v0.2.5 h1:2GTftHqmUhVOeuu9CW3kwDkRe4pcBDq0uuK5VJngU1M= -github.com/hashicorp/terraform-registry-address v0.2.5/go.mod h1:PpzXWINwB5kuVS5CA7m1+eO2f1jKb5ZDIxrOPfpnGkg= +github.com/hashicorp/terraform-registry-address v0.3.0 h1:HMpK3nqaGFPS9VmgRXrJL/dzHNdheGVKk5k7VlFxzCo= +github.com/hashicorp/terraform-registry-address v0.3.0/go.mod h1:jRGCMiLaY9zii3GLC7hqpSnwhfnCN5yzvY0hh4iCGbM= github.com/hashicorp/terraform-svchost v0.1.1 h1:EZZimZ1GxdqFRinZ1tpJwVxxt49xc/S52uzrw4x0jKQ= github.com/hashicorp/terraform-svchost v0.1.1/go.mod h1:mNsjQfZyf/Jhz35v6/0LWcv26+X7JPS+buii2c9/ctc= github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= @@ -56,32 +56,32 @@ github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAh github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= -go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= -go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= -go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= -go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= -go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= -go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= -go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= -go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= -go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= -golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= -golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= +go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= +go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= +go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= +go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= +go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= +go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 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.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ= -google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA= -google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= +google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/fwschemadata/data_description.go b/internal/fwschemadata/data_description.go index 282a53321..fe3d71cf8 100644 --- a/internal/fwschemadata/data_description.go +++ b/internal/fwschemadata/data_description.go @@ -12,6 +12,10 @@ const ( // a plan-based value. DataDescriptionPlan DataDescription = "plan" + // DataDescriptionResource is used for Data that represents + // a resource value. + DataDescriptionResource DataDescription = "resource" + // DataDescriptionState is used for Data that represents // a state-based value. DataDescriptionState DataDescription = "state" diff --git a/internal/fwserver/server.go b/internal/fwserver/server.go index e3cb1aefb..f178b21aa 100644 --- a/internal/fwserver/server.go +++ b/internal/fwserver/server.go @@ -125,6 +125,16 @@ type Server struct { // access from race conditions. listResourceFuncsMutex sync.Mutex + // listResourceSchemas is the cached ListResource Schemas for RPCs that + // need to convert configuration data from the protocol. If not found, it + // will be fetched from the [list.ListResource.ListResourceConfigSchema] + // method. + listResourceSchemas map[string]fwschema.Schema + + // listResourceSchemasMutex is a mutex to protect concurrent + // listResourceSchemas access from race conditions. + listResourceSchemasMutex sync.RWMutex + // providerSchema is the cached Provider Schema for RPCs that need to // convert configuration data from the protocol. If not found, it will be // fetched from the Provider.GetSchema() method. diff --git a/internal/fwserver/server_listresource.go b/internal/fwserver/server_listresource.go new file mode 100644 index 000000000..37adb9ff3 --- /dev/null +++ b/internal/fwserver/server_listresource.go @@ -0,0 +1,147 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package fwserver + +import ( + "context" + "errors" + "iter" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/logging" + "github.com/hashicorp/terraform-plugin-framework/list" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" +) + +// ListRequest is the framework server request for the ListResource RPC. +type ListRequest struct { + // ListResource is an instance of the provider's list resource + // 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 + + ResourceSchema fwschema.Schema + ResourceIdentitySchema fwschema.Schema +} + +// 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]. +type ListResultsStream struct { + // Results is a function that emits [ListResult] values via its push + // function argument. + Results iter.Seq[ListResult] +} + +func ListResultError(summary string, detail string) ListResult { + return ListResult{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic(summary, detail), + }, + } +} + +// ListResult represents a listed managed resource instance. +type ListResult struct { + // Identity is the identity of the managed resource instance. A nil value + // will raise an error 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 +} + +var NoListResults = func(func(ListResult) bool) {} + +// ListResource implements the framework server ListResource RPC. +func (s *Server) ListResource(ctx context.Context, fwReq *ListRequest, fwStream *ListResultsStream) error { + listResource := fwReq.ListResource + + if fwReq.Config == nil { + fwStream.Results = NoListResults + return errors.New("Invalid ListResource request: Config cannot be nil") + } + + req := list.ListRequest{ + Config: *fwReq.Config, + IncludeResource: fwReq.IncludeResource, + ResourceSchema: fwReq.ResourceSchema, + ResourceIdentitySchema: fwReq.ResourceIdentitySchema, + } + + stream := &list.ListResultsStream{} + + logging.FrameworkTrace(ctx, "Calling provider defined ListResource") + listResource.List(ctx, req, stream) + logging.FrameworkTrace(ctx, "Called provider defined ListResource") + + // If the provider returned a nil results stream, we return an empty stream. + if stream.Results == nil { + 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.Diagnostics.HasError() { + return ListResult(result) + } + + if result.Identity == nil { // TODO: is result.Identity.Raw.IsNull() a practical concern? + return ListResultError( + "Incomplete List Result", + "The provider did not populate the Identity field in the ListResourceResult. This may be due to an error in the provider's implementation.", + ) + } + + if req.IncludeResource { + if result.Resource == nil { // TODO: is result.Resource.Raw.IsNull() a practical concern? + result.Diagnostics.AddWarning( + "Incomplete List Result", + "The provider did not populate the Resource field in the ListResourceResult. This may be due to the provider not supporting this functionality or an error in the provider's implementation.", + ) + } + } + + 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..f404d29fa --- /dev/null +++ b/internal/fwserver/server_listresource_test.go @@ -0,0 +1,273 @@ +// 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"), + }) + + testCases := map[string]struct { + server *fwserver.Server + request *fwserver.ListRequest + expectedStreamEvents []fwserver.ListResult + expectedError string + }{ + "success-with-zero-results": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.ListRequest{ + Config: &tfsdk.Config{}, + ListResource: &testprovider.ListResource{ + ListMethod: func(ctx context.Context, req list.ListRequest, resp *list.ListResultsStream) { + resp.Results = list.NoListResults + }, + }, + }, + expectedStreamEvents: []fwserver.ListResult{}, + }, + "success-with-nil-results": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.ListRequest{ + Config: &tfsdk.Config{}, + ListResource: &testprovider.ListResource{ + ListMethod: func(ctx context.Context, req list.ListRequest, resp *list.ListResultsStream) { + // Do nothing, so that resp.Results is nil + }, + }, + }, + expectedStreamEvents: []fwserver.ListResult{}, + }, + "success-with-multiple-results": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.ListRequest{ + Config: &tfsdk.Config{}, + ListResource: &testprovider.ListResource{ + ListMethod: func(ctx context.Context, req list.ListRequest, resp *list.ListResultsStream) { + 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{}, + }, + }, + }, + "error-on-nil-config": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.ListRequest{ + Config: nil, + ListResource: &testprovider.ListResource{ + ListMethod: func(ctx context.Context, req list.ListRequest, resp *list.ListResultsStream) { + resp.Results = list.NoListResults + }, + }, + }, + expectedError: "Invalid ListResource request: Config cannot be nil", + expectedStreamEvents: []fwserver.ListResult{}, + }, + "error-on-nil-resource-identity": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.ListRequest{ + Config: &tfsdk.Config{}, + ListResource: &testprovider.ListResource{ + ListMethod: func(ctx context.Context, req list.ListRequest, resp *list.ListResultsStream) { + resp.Results = slices.Values([]list.ListResult{ + { + Identity: nil, + Resource: &tfsdk.Resource{ + Schema: testSchema, + Raw: testResourceValue1, + }, + DisplayName: "Test Resource 1", + }, + }) + }, + }, + }, + expectedStreamEvents: []fwserver.ListResult{ + { + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Incomplete List Result", + "The provider did not populate the Identity field in the ListResourceResult. This may be due to an error in the provider's implementation.", + ), + }, + }, + }, + }, + "warning-on-missing-resource": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.ListRequest{ + Config: &tfsdk.Config{}, + IncludeResource: true, + ListResource: &testprovider.ListResource{ + ListMethod: func(ctx context.Context, req list.ListRequest, resp *list.ListResultsStream) { + resp.Results = slices.Values([]list.ListResult{ + { + Identity: &tfsdk.ResourceIdentity{ + Schema: testIdentitySchema, + Raw: testIdentityValue1, + }, + Resource: nil, + DisplayName: "Test Resource 1", + }, + }) + }, + }, + }, + expectedStreamEvents: []fwserver.ListResult{ + { + Identity: &tfsdk.ResourceIdentity{ + Schema: testIdentitySchema, + Raw: testIdentityValue1, + }, + DisplayName: "Test Resource 1", + Diagnostics: diag.Diagnostics{ + diag.NewWarningDiagnostic( + "Incomplete List Result", + "The provider did not populate the Resource field in the ListResourceResult. This may be due to the provider not supporting this functionality or an error in the provider's implementation.", + ), + }, + }, + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + response := &fwserver.ListResultsStream{} + err := testCase.server.ListResource(context.Background(), testCase.request, response) + if err != nil && err.Error() != testCase.expectedError { + t.Fatalf("unexpected error: %s", err) + } + + events := slices.AppendSeq([]fwserver.ListResult{}, response.Results) + if diff := cmp.Diff(events, testCase.expectedStreamEvents); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/internal/fwserver/server_listresources.go b/internal/fwserver/server_listresources.go index 569e43bfc..94760afb3 100644 --- a/internal/fwserver/server_listresources.go +++ b/internal/fwserver/server_listresources.go @@ -15,6 +15,22 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource" ) +func (s *Server) ListResourceType(ctx context.Context, typeName string) (list.ListResource, diag.Diagnostics) { + listResourceFuncs, diags := s.ListResourceFuncs(ctx) + listResourceFunc, ok := listResourceFuncs[typeName] + + if !ok { + diags.AddError( + "List Resource Type Not Found", + fmt.Sprintf("No list resource type named %q was found in the provider.", typeName), + ) + + return nil, diags + } + + return listResourceFunc(), nil +} + // ListResourceFuncs returns a map of ListResource functions. The results are // cached on first use. func (s *Server) ListResourceFuncs(ctx context.Context) (map[string]func() list.ListResource, diag.Diagnostics) { @@ -69,7 +85,8 @@ func (s *Server) ListResourceFuncs(ctx context.Context) (map[string]func() list. continue } - if _, ok := s.resourceFuncs[typeName]; !ok { + resourceFuncs, _ := s.ResourceFuncs(ctx) + if _, ok := resourceFuncs[typeName]; !ok { s.listResourceFuncsDiags.AddError( "ListResource Type Defined without a Matching Managed Resource Type", fmt.Sprintf("The %s ListResource type name was returned, but no matching managed Resource type was defined. ", typeName)+ @@ -87,17 +104,61 @@ func (s *Server) ListResourceFuncs(ctx context.Context) (map[string]func() list. // ListResourceMetadatas returns a slice of ListResourceMetadata for the GetMetadata // RPC. func (s *Server) ListResourceMetadatas(ctx context.Context) ([]ListResourceMetadata, diag.Diagnostics) { - resourceFuncs, diags := s.ListResourceFuncs(ctx) + listResourceFuncs, diags := s.ListResourceFuncs(ctx) - resourceMetadatas := make([]ListResourceMetadata, 0, len(resourceFuncs)) + listResourceMetadatas := make([]ListResourceMetadata, 0, len(listResourceFuncs)) - for typeName := range resourceFuncs { - resourceMetadatas = append(resourceMetadatas, ListResourceMetadata{ + for typeName := range listResourceFuncs { + listResourceMetadatas = append(listResourceMetadatas, ListResourceMetadata{ TypeName: typeName, }) } - return resourceMetadatas, diags + return listResourceMetadatas, diags +} + +// ListResourceSchema returns the ListResource Schema for the given type name and +// caches the result for later ListResource operations. +func (s *Server) ListResourceSchema(ctx context.Context, typeName string) (fwschema.Schema, diag.Diagnostics) { + s.listResourceSchemasMutex.RLock() + listResourceSchema, ok := s.listResourceSchemas[typeName] + s.listResourceSchemasMutex.RUnlock() + + if ok { + return listResourceSchema, nil + } + + var diags diag.Diagnostics + + listResource, listResourceDiags := s.ListResourceType(ctx, typeName) + diags.Append(listResourceDiags...) + if diags.HasError() { + return nil, diags + } + + schemaReq := list.ListResourceSchemaRequest{} + schemaResp := list.ListResourceSchemaResponse{} + + logging.FrameworkTrace(ctx, "Calling provider defined ListResourceConfigSchema method", map[string]interface{}{logging.KeyListResourceType: typeName}) + listResource.ListResourceConfigSchema(ctx, schemaReq, &schemaResp) + logging.FrameworkTrace(ctx, "Called provider defined ListResourceConfigSchema method", map[string]interface{}{logging.KeyListResourceType: typeName}) + + diags.Append(schemaResp.Diagnostics...) + if diags.HasError() { + return schemaResp.Schema, diags + } + + s.listResourceSchemasMutex.Lock() + + if s.listResourceSchemas == nil { + s.listResourceSchemas = make(map[string]fwschema.Schema) + } + + s.listResourceSchemas[typeName] = schemaResp.Schema + + s.listResourceSchemasMutex.Unlock() + + return schemaResp.Schema, diags } // ListResourceSchemas returns a map of ListResource Schemas for the diff --git a/internal/proto5server/server_listresource.go b/internal/proto5server/server_listresource.go new file mode 100644 index 000000000..af85693c4 --- /dev/null +++ b/internal/proto5server/server_listresource.go @@ -0,0 +1,91 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package proto5server + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fromproto5" + "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" + "github.com/hashicorp/terraform-plugin-framework/internal/toproto5" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" +) + +// ListRequestErrorDiagnostics returns a value suitable for +// [ListResourceServerStream.Results]. It yields a single result that contains +// the given error diagnostics. +func ListRequestErrorDiagnostics(ctx context.Context, diags ...diag.Diagnostic) (*tfprotov5.ListResourceServerStream, error) { + protoDiags := toproto5.Diagnostics(ctx, diags) + return &tfprotov5.ListResourceServerStream{ + Results: func(push func(tfprotov5.ListResourceResult) bool) { + push(tfprotov5.ListResourceResult{Diagnostics: protoDiags}) + }, + }, nil +} + +func (s *Server) ListResource(ctx context.Context, protoReq *tfprotov5.ListResourceRequest) (*tfprotov5.ListResourceServerStream, error) { + protoStream := &tfprotov5.ListResourceServerStream{Results: tfprotov5.NoListResults} + allDiags := diag.Diagnostics{} + + listResource, diags := s.FrameworkServer.ListResourceType(ctx, protoReq.TypeName) + allDiags.Append(diags...) + if diags.HasError() { + return ListRequestErrorDiagnostics(ctx, allDiags...) + } + + listResourceSchema, diags := s.FrameworkServer.ListResourceSchema(ctx, protoReq.TypeName) + allDiags.Append(diags...) + if diags.HasError() { + return ListRequestErrorDiagnostics(ctx, allDiags...) + } + + config, diags := fromproto5.Config(ctx, protoReq.Config, listResourceSchema) + allDiags.Append(diags...) + if diags.HasError() { + return ListRequestErrorDiagnostics(ctx, allDiags...) + } + + resourceSchema, diags := s.FrameworkServer.ResourceSchema(ctx, protoReq.TypeName) + allDiags.Append(diags...) + if diags.HasError() { + return ListRequestErrorDiagnostics(ctx, allDiags...) + } + + identitySchema, diags := s.FrameworkServer.ResourceIdentitySchema(ctx, protoReq.TypeName) + allDiags.Append(diags...) + if diags.HasError() { + return ListRequestErrorDiagnostics(ctx, allDiags...) + } + + req := &fwserver.ListRequest{ + Config: config, + ListResource: listResource, + ResourceSchema: resourceSchema, + ResourceIdentitySchema: identitySchema, + IncludeResource: protoReq.IncludeResource, + } + stream := &fwserver.ListResultsStream{} + + err := s.FrameworkServer.ListResource(ctx, req, stream) + if err != nil { + return protoStream, err + } + + protoStream.Results = func(push func(tfprotov5.ListResourceResult) bool) { + for result := range stream.Results { + var protoResult tfprotov5.ListResourceResult + if req.IncludeResource { + protoResult = toproto5.ListResourceResultWithResource(ctx, &result) + } else { + protoResult = toproto5.ListResourceResult(ctx, &result) + } + + if !push(protoResult) { + return + } + } + } + return protoStream, nil +} diff --git a/internal/proto5server/server_listresource_test.go b/internal/proto5server/server_listresource_test.go new file mode 100644 index 000000000..69cc206c9 --- /dev/null +++ b/internal/proto5server/server_listresource_test.go @@ -0,0 +1,309 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package proto5server + +import ( + "context" + "slices" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "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" + listschema "github.com/hashicorp/terraform-plugin-framework/list/schema" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/identityschema" + resourceschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestServerListResource(t *testing.T) { + t.Parallel() + + type ThingResourceIdentity struct { + Id string `tfsdk:"id"` + } + + type ThingResource struct { + // TODO: how do we feel about this? + ThingResourceIdentity + Name string `tfsdk:"name"` + } + + resources := map[string]ThingResource{} + expectedResources := map[string]*tfprotov5.DynamicValue{} + expectedResourceIdentities := map[string]*tfprotov5.ResourceIdentityData{} + + examples := []string{"bookbag", "bookshelf", "bookworm", "plateau", "platinum", "platypus"} + for _, example := range examples { + id := "id-" + example + resources[example] = ThingResource{Name: example, ThingResourceIdentity: ThingResourceIdentity{Id: id}} + + expectedResources[example] = testNewDynamicValue(t, tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "id": tftypes.String, + "name": tftypes.String, + }, + }, map[string]tftypes.Value{ + "id": tftypes.NewValue(tftypes.String, id), + "name": tftypes.NewValue(tftypes.String, example), + }) + + expectedResourceIdentities[example] = &tfprotov5.ResourceIdentityData{ + IdentityData: testNewDynamicValue(t, tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "id": tftypes.String, + }, + }, map[string]tftypes.Value{ + "id": tftypes.NewValue(tftypes.String, id), + }), + } + } + + listResourceType := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "filter": tftypes.String, + }, + } + + type listConfig struct { + Filter string `tfsdk:"filter"` + } + + plat := testNewDynamicValue(t, listResourceType, map[string]tftypes.Value{ + "filter": tftypes.NewValue(tftypes.String, "plat"), + }) + + plateau := testNewDynamicValue(t, listResourceType, map[string]tftypes.Value{ + "filter": tftypes.NewValue(tftypes.String, "plateau"), + }) + + listResource := func() list.ListResource { + return &testprovider.ListResource{ + ListResourceConfigSchemaMethod: func(ctx context.Context, req list.ListResourceSchemaRequest, resp *list.ListResourceSchemaResponse) { + resp.Schema = listschema.Schema{ + Attributes: map[string]listschema.Attribute{ + "filter": listschema.StringAttribute{}, + }, + } + }, + ListMethod: func(ctx context.Context, req list.ListRequest, resp *list.ListResultsStream) { + var config listConfig + diags := req.Config.Get(ctx, &config) + if len(diags) > 0 { + t.Fatalf("unexpected diagnostics: %s", diags) + } + + results := []list.ListResult{} + for name := range resources { + if !strings.HasPrefix(name, config.Filter) { + continue + } + + result := req.ToListResult(ctx, resources[name].ThingResourceIdentity, resources[name], name) + results = append(results, result) + } + resp.Results = slices.Values(results) + }, + MetadataMethod: func(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "test_resource" + }, + } + } + + listResourceThatDoesNotPopulateResource := func() list.ListResource { + r, ok := listResource().(*testprovider.ListResource) + if !ok { + t.Fatal("listResourceThatDoesNotPopulateResource must be a testprovider.ListResource") + } + + r.ListMethod = func(ctx context.Context, req list.ListRequest, resp *list.ListResultsStream) { + result := req.ToListResult(ctx, resources["plateau"].ThingResourceIdentity, nil, "plateau") + + resp.Results = slices.Values([]list.ListResult{result}) + } + + return r + } + + managedResource := func() resource.Resource { + return &testprovider.ResourceWithIdentity{ + IdentitySchemaMethod: func(ctx context.Context, req resource.IdentitySchemaRequest, resp *resource.IdentitySchemaResponse) { + resp.IdentitySchema = identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "id": identityschema.StringAttribute{}, + }, + } + }, + Resource: &testprovider.Resource{ + MetadataMethod: func(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "test_resource" + }, + SchemaMethod: func(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = resourceschema.Schema{ + Attributes: map[string]resourceschema.Attribute{ + "id": resourceschema.StringAttribute{}, + "name": resourceschema.StringAttribute{}, + }, + } + }, + }, + } + } + + server := func(listResource func() list.ListResource, managedResource func() resource.Resource) *Server { + return &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ListResourcesMethod: func(ctx context.Context) []func() list.ListResource { + return []func() list.ListResource{ + listResource, + } + }, + ResourcesMethod: func(ctx context.Context) []func() resource.Resource { + return []func() resource.Resource{ + managedResource, + } + }, + }, + }, + } + } + + happyServer := server(listResource, managedResource) + + testCases := map[string]struct { + server *Server + request *tfprotov5.ListResourceRequest + expectedError error + expectedDiagnostics diag.Diagnostics + expectedResults []tfprotov5.ListResourceResult + }{ + "error-on-unknown-list-resource-type": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ListResourcesMethod: func(ctx context.Context) []func() list.ListResource { + return []func() list.ListResource{} + }, + }, + }, + }, + request: &tfprotov5.ListResourceRequest{ + TypeName: "test_resource", + Config: plat, + }, + expectedDiagnostics: diag.Diagnostics{}, + expectedResults: []tfprotov5.ListResourceResult{ + { + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "List Resource Type Not Found", + }, + }, + }, + }, + }, + "result": { + server: happyServer, + request: &tfprotov5.ListResourceRequest{ + TypeName: "test_resource", + Config: plat, + }, + expectedError: nil, + expectedDiagnostics: diag.Diagnostics{}, + expectedResults: []tfprotov5.ListResourceResult{ + { + DisplayName: "plateau", + Identity: expectedResourceIdentities["plateau"], + }, + { + DisplayName: "platinum", + Identity: expectedResourceIdentities["platinum"], + }, + { + DisplayName: "platypus", + Identity: expectedResourceIdentities["platypus"], + }, + }, + }, + "result-with-include-resource": { + server: happyServer, + request: &tfprotov5.ListResourceRequest{ + TypeName: "test_resource", + Config: plateau, + IncludeResource: true, + }, + expectedError: nil, + expectedDiagnostics: diag.Diagnostics{}, + expectedResults: []tfprotov5.ListResourceResult{ + { + DisplayName: "plateau", + Identity: expectedResourceIdentities["plateau"], + Resource: expectedResources["plateau"], + }, + }, + }, + "result-with-include-resource-warning": { + server: server(listResourceThatDoesNotPopulateResource, managedResource), + request: &tfprotov5.ListResourceRequest{ + TypeName: "test_resource", + Config: plateau, + IncludeResource: true, + }, + expectedError: nil, + expectedDiagnostics: diag.Diagnostics{}, + expectedResults: []tfprotov5.ListResourceResult{ + { + DisplayName: "plateau", + Identity: expectedResourceIdentities["plateau"], + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityWarning, + Summary: "Incomplete List Result", + Detail: "The provider did not populate the Resource field in the ListResourceResult. This may be due to the provider not supporting this functionality or an error in the provider's implementation.", + }, + }, + }, + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + metadataResponse := &fwserver.GetMetadataResponse{} + testCase.server.FrameworkServer.GetMetadata(context.Background(), &fwserver.GetMetadataRequest{}, metadataResponse) + + if diff := cmp.Diff(metadataResponse.Diagnostics, diag.Diagnostics{}); diff != "" { + t.Fatalf("unexpected metadata diagnostics difference: got %s\nwanted %s", metadataResponse.Diagnostics, diag.Diagnostics{}) + } + + got, err := testCase.server.ListResource(context.Background(), testCase.request) + + if diff := cmp.Diff(testCase.expectedError, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("unexpected error difference: %s", diff) + } + + sortResults := cmpopts.SortSlices(func(a, b tfprotov5.ListResourceResult) bool { + return a.DisplayName < b.DisplayName + }) + opts := []cmp.Option{ + sortResults, + cmpopts.EquateEmpty(), + cmpopts.IgnoreFields(tfprotov5.Diagnostic{}, "Detail"), + } + if diff := cmp.Diff(testCase.expectedResults, slices.Collect(got.Results), opts...); diff != "" { + t.Errorf("unexpected results difference: %s", diff) + } + }) + } +} diff --git a/internal/proto5server/server_validatelistresourceconfig.go b/internal/proto5server/server_validatelistresourceconfig.go new file mode 100644 index 000000000..40c47ae15 --- /dev/null +++ b/internal/proto5server/server_validatelistresourceconfig.go @@ -0,0 +1,14 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package proto5server + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-go/tfprotov5" +) + +func (s *Server) ValidateListResourceConfig(ctx context.Context, request *tfprotov5.ValidateListResourceConfigRequest) (*tfprotov5.ValidateListResourceConfigResponse, error) { + return &tfprotov5.ValidateListResourceConfigResponse{}, nil +} diff --git a/internal/proto6server/server_listresource.go b/internal/proto6server/server_listresource.go new file mode 100644 index 000000000..5a5bf0b4c --- /dev/null +++ b/internal/proto6server/server_listresource.go @@ -0,0 +1,91 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package proto6server + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fromproto6" + "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" + "github.com/hashicorp/terraform-plugin-framework/internal/toproto6" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" +) + +// ListRequestErrorDiagnostics returns a value suitable for +// [ListResourceServerStream.Results]. It yields a single result that contains +// the given error diagnostics. +func ListRequestErrorDiagnostics(ctx context.Context, diags ...diag.Diagnostic) (*tfprotov6.ListResourceServerStream, error) { + protoDiags := toproto6.Diagnostics(ctx, diags) + return &tfprotov6.ListResourceServerStream{ + Results: func(push func(tfprotov6.ListResourceResult) bool) { + push(tfprotov6.ListResourceResult{Diagnostics: protoDiags}) + }, + }, nil +} + +func (s *Server) ListResource(ctx context.Context, protoReq *tfprotov6.ListResourceRequest) (*tfprotov6.ListResourceServerStream, error) { + protoStream := &tfprotov6.ListResourceServerStream{Results: tfprotov6.NoListResults} + allDiags := diag.Diagnostics{} + + listResource, diags := s.FrameworkServer.ListResourceType(ctx, protoReq.TypeName) + allDiags.Append(diags...) + if diags.HasError() { + return ListRequestErrorDiagnostics(ctx, allDiags...) + } + + listResourceSchema, diags := s.FrameworkServer.ListResourceSchema(ctx, protoReq.TypeName) + allDiags.Append(diags...) + if diags.HasError() { + return ListRequestErrorDiagnostics(ctx, allDiags...) + } + + config, diags := fromproto6.Config(ctx, protoReq.Config, listResourceSchema) + allDiags.Append(diags...) + if diags.HasError() { + return ListRequestErrorDiagnostics(ctx, allDiags...) + } + + resourceSchema, diags := s.FrameworkServer.ResourceSchema(ctx, protoReq.TypeName) + allDiags.Append(diags...) + if diags.HasError() { + return ListRequestErrorDiagnostics(ctx, allDiags...) + } + + identitySchema, diags := s.FrameworkServer.ResourceIdentitySchema(ctx, protoReq.TypeName) + allDiags.Append(diags...) + if diags.HasError() { + return ListRequestErrorDiagnostics(ctx, allDiags...) + } + + req := &fwserver.ListRequest{ + Config: config, + ListResource: listResource, + ResourceSchema: resourceSchema, + ResourceIdentitySchema: identitySchema, + IncludeResource: protoReq.IncludeResource, + } + stream := &fwserver.ListResultsStream{} + + err := s.FrameworkServer.ListResource(ctx, req, stream) + if err != nil { + return protoStream, err + } + + protoStream.Results = func(push func(tfprotov6.ListResourceResult) bool) { + for result := range stream.Results { + var protoResult tfprotov6.ListResourceResult + if req.IncludeResource { + protoResult = toproto6.ListResourceResultWithResource(ctx, &result) + } else { + protoResult = toproto6.ListResourceResult(ctx, &result) + } + + if !push(protoResult) { + return + } + } + } + return protoStream, nil +} diff --git a/internal/proto6server/server_listresource_test.go b/internal/proto6server/server_listresource_test.go new file mode 100644 index 000000000..162666484 --- /dev/null +++ b/internal/proto6server/server_listresource_test.go @@ -0,0 +1,309 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package proto6server + +import ( + "context" + "slices" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "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" + listschema "github.com/hashicorp/terraform-plugin-framework/list/schema" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/identityschema" + resourceschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestServerListResource(t *testing.T) { + t.Parallel() + + type ThingResourceIdentity struct { + Id string `tfsdk:"id"` + } + + type ThingResource struct { + // TODO: how do we feel about this? + ThingResourceIdentity + Name string `tfsdk:"name"` + } + + resources := map[string]ThingResource{} + expectedResources := map[string]*tfprotov6.DynamicValue{} + expectedResourceIdentities := map[string]*tfprotov6.ResourceIdentityData{} + + examples := []string{"bookbag", "bookshelf", "bookworm", "plateau", "platinum", "platypus"} + for _, example := range examples { + id := "id-" + example + resources[example] = ThingResource{Name: example, ThingResourceIdentity: ThingResourceIdentity{Id: id}} + + expectedResources[example] = testNewDynamicValue(t, tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "id": tftypes.String, + "name": tftypes.String, + }, + }, map[string]tftypes.Value{ + "id": tftypes.NewValue(tftypes.String, id), + "name": tftypes.NewValue(tftypes.String, example), + }) + + expectedResourceIdentities[example] = &tfprotov6.ResourceIdentityData{ + IdentityData: testNewDynamicValue(t, tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "id": tftypes.String, + }, + }, map[string]tftypes.Value{ + "id": tftypes.NewValue(tftypes.String, id), + }), + } + } + + listResourceType := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "filter": tftypes.String, + }, + } + + type listConfig struct { + Filter string `tfsdk:"filter"` + } + + plat := testNewDynamicValue(t, listResourceType, map[string]tftypes.Value{ + "filter": tftypes.NewValue(tftypes.String, "plat"), + }) + + plateau := testNewDynamicValue(t, listResourceType, map[string]tftypes.Value{ + "filter": tftypes.NewValue(tftypes.String, "plateau"), + }) + + listResource := func() list.ListResource { + return &testprovider.ListResource{ + ListResourceConfigSchemaMethod: func(ctx context.Context, req list.ListResourceSchemaRequest, resp *list.ListResourceSchemaResponse) { + resp.Schema = listschema.Schema{ + Attributes: map[string]listschema.Attribute{ + "filter": listschema.StringAttribute{}, + }, + } + }, + ListMethod: func(ctx context.Context, req list.ListRequest, resp *list.ListResultsStream) { + var config listConfig + diags := req.Config.Get(ctx, &config) + if len(diags) > 0 { + t.Fatalf("unexpected diagnostics: %s", diags) + } + + results := []list.ListResult{} + for name := range resources { + if !strings.HasPrefix(name, config.Filter) { + continue + } + + result := req.ToListResult(ctx, resources[name].ThingResourceIdentity, resources[name], name) + results = append(results, result) + } + resp.Results = slices.Values(results) + }, + MetadataMethod: func(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "test_resource" + }, + } + } + + listResourceThatDoesNotPopulateResource := func() list.ListResource { + r, ok := listResource().(*testprovider.ListResource) + if !ok { + t.Fatal("listResourceThatDoesNotPopulateResource must be a testprovider.ListResource") + } + + r.ListMethod = func(ctx context.Context, req list.ListRequest, resp *list.ListResultsStream) { + result := req.ToListResult(ctx, resources["plateau"].ThingResourceIdentity, nil, "plateau") + + resp.Results = slices.Values([]list.ListResult{result}) + } + + return r + } + + managedResource := func() resource.Resource { + return &testprovider.ResourceWithIdentity{ + IdentitySchemaMethod: func(ctx context.Context, req resource.IdentitySchemaRequest, resp *resource.IdentitySchemaResponse) { + resp.IdentitySchema = identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "id": identityschema.StringAttribute{}, + }, + } + }, + Resource: &testprovider.Resource{ + MetadataMethod: func(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "test_resource" + }, + SchemaMethod: func(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = resourceschema.Schema{ + Attributes: map[string]resourceschema.Attribute{ + "id": resourceschema.StringAttribute{}, + "name": resourceschema.StringAttribute{}, + }, + } + }, + }, + } + } + + server := func(listResource func() list.ListResource, managedResource func() resource.Resource) *Server { + return &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ListResourcesMethod: func(ctx context.Context) []func() list.ListResource { + return []func() list.ListResource{ + listResource, + } + }, + ResourcesMethod: func(ctx context.Context) []func() resource.Resource { + return []func() resource.Resource{ + managedResource, + } + }, + }, + }, + } + } + + happyServer := server(listResource, managedResource) + + testCases := map[string]struct { + server *Server + request *tfprotov6.ListResourceRequest + expectedError error + expectedDiagnostics diag.Diagnostics + expectedResults []tfprotov6.ListResourceResult + }{ + "error-on-unknown-list-resource-type": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ListResourcesMethod: func(ctx context.Context) []func() list.ListResource { + return []func() list.ListResource{} + }, + }, + }, + }, + request: &tfprotov6.ListResourceRequest{ + TypeName: "test_resource", + Config: plat, + }, + expectedDiagnostics: diag.Diagnostics{}, + expectedResults: []tfprotov6.ListResourceResult{ + { + Diagnostics: []*tfprotov6.Diagnostic{ + { + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "List Resource Type Not Found", + }, + }, + }, + }, + }, + "result": { + server: happyServer, + request: &tfprotov6.ListResourceRequest{ + TypeName: "test_resource", + Config: plat, + }, + expectedError: nil, + expectedDiagnostics: diag.Diagnostics{}, + expectedResults: []tfprotov6.ListResourceResult{ + { + DisplayName: "plateau", + Identity: expectedResourceIdentities["plateau"], + }, + { + DisplayName: "platinum", + Identity: expectedResourceIdentities["platinum"], + }, + { + DisplayName: "platypus", + Identity: expectedResourceIdentities["platypus"], + }, + }, + }, + "result-with-include-resource": { + server: happyServer, + request: &tfprotov6.ListResourceRequest{ + TypeName: "test_resource", + Config: plateau, + IncludeResource: true, + }, + expectedError: nil, + expectedDiagnostics: diag.Diagnostics{}, + expectedResults: []tfprotov6.ListResourceResult{ + { + DisplayName: "plateau", + Identity: expectedResourceIdentities["plateau"], + Resource: expectedResources["plateau"], + }, + }, + }, + "result-with-include-resource-warning": { + server: server(listResourceThatDoesNotPopulateResource, managedResource), + request: &tfprotov6.ListResourceRequest{ + TypeName: "test_resource", + Config: plateau, + IncludeResource: true, + }, + expectedError: nil, + expectedDiagnostics: diag.Diagnostics{}, + expectedResults: []tfprotov6.ListResourceResult{ + { + DisplayName: "plateau", + Identity: expectedResourceIdentities["plateau"], + Diagnostics: []*tfprotov6.Diagnostic{ + { + Severity: tfprotov6.DiagnosticSeverityWarning, + Summary: "Incomplete List Result", + Detail: "The provider did not populate the Resource field in the ListResourceResult. This may be due to the provider not supporting this functionality or an error in the provider's implementation.", + }, + }, + }, + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + metadataResponse := &fwserver.GetMetadataResponse{} + testCase.server.FrameworkServer.GetMetadata(context.Background(), &fwserver.GetMetadataRequest{}, metadataResponse) + + if diff := cmp.Diff(metadataResponse.Diagnostics, diag.Diagnostics{}); diff != "" { + t.Fatalf("unexpected metadata diagnostics difference: got %s\nwanted %s", metadataResponse.Diagnostics, diag.Diagnostics{}) + } + + got, err := testCase.server.ListResource(context.Background(), testCase.request) + + if diff := cmp.Diff(testCase.expectedError, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("unexpected error difference: %s", diff) + } + + sortResults := cmpopts.SortSlices(func(a, b tfprotov6.ListResourceResult) bool { + return a.DisplayName < b.DisplayName + }) + opts := []cmp.Option{ + sortResults, + cmpopts.EquateEmpty(), + cmpopts.IgnoreFields(tfprotov6.Diagnostic{}, "Detail"), + } + if diff := cmp.Diff(testCase.expectedResults, slices.Collect(got.Results), opts...); diff != "" { + t.Errorf("unexpected results difference: %s", diff) + } + }) + } +} diff --git a/internal/proto6server/server_validatelistresourceconfig.go b/internal/proto6server/server_validatelistresourceconfig.go new file mode 100644 index 000000000..f3b282018 --- /dev/null +++ b/internal/proto6server/server_validatelistresourceconfig.go @@ -0,0 +1,14 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package proto6server + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-go/tfprotov6" +) + +func (s *Server) ValidateListResourceConfig(ctx context.Context, request *tfprotov6.ValidateListResourceConfigRequest) (*tfprotov6.ValidateListResourceConfigResponse, error) { + return &tfprotov6.ValidateListResourceConfigResponse{}, nil +} diff --git a/internal/toproto5/list_resource_result.go b/internal/toproto5/list_resource_result.go new file mode 100644 index 000000000..6ed77d12a --- /dev/null +++ b/internal/toproto5/list_resource_result.go @@ -0,0 +1,51 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package toproto5 + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" +) + +func ListResourceResult(ctx context.Context, result *fwserver.ListResult) tfprotov5.ListResourceResult { + allDiags := result.Diagnostics + if allDiags.HasError() { + return tfprotov5.ListResourceResult{ + Diagnostics: Diagnostics(ctx, allDiags), + } + } + + resourceIdentity, diags := ResourceIdentity(ctx, result.Identity) + allDiags.Append(diags...) + + return tfprotov5.ListResourceResult{ + DisplayName: result.DisplayName, + Identity: resourceIdentity, + Diagnostics: Diagnostics(ctx, allDiags), + } +} + +func ListResourceResultWithResource(ctx context.Context, result *fwserver.ListResult) tfprotov5.ListResourceResult { + allDiags := result.Diagnostics + if allDiags.HasError() { + return tfprotov5.ListResourceResult{ + Diagnostics: Diagnostics(ctx, allDiags), + } + } + + resourceIdentity, diags := ResourceIdentity(ctx, result.Identity) + allDiags.Append(diags...) + + resource, diags := Resource(ctx, result.Resource) + allDiags.Append(diags...) + + return tfprotov5.ListResourceResult{ + DisplayName: result.DisplayName, + Identity: resourceIdentity, + Resource: resource, + Diagnostics: Diagnostics(ctx, allDiags), + } +} diff --git a/internal/toproto5/listresourcemetadata.go b/internal/toproto5/listresourcemetadata.go new file mode 100644 index 000000000..f27ab46f3 --- /dev/null +++ b/internal/toproto5/listresourcemetadata.go @@ -0,0 +1,19 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package toproto5 + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" +) + +// ListResourceMetadata returns the tfprotov5.ListResourceMetadata for a +// fwserver.ListResourceMetadata. +func ListResourceMetadata(ctx context.Context, fw fwserver.ListResourceMetadata) tfprotov5.ListResourceMetadata { + return tfprotov5.ListResourceMetadata{ + TypeName: fw.TypeName, + } +} diff --git a/internal/toproto5/resource.go b/internal/toproto5/resource.go new file mode 100644 index 000000000..76f7b8fb4 --- /dev/null +++ b/internal/toproto5/resource.go @@ -0,0 +1,28 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package toproto5 + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschemadata" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" +) + +// Resource returns the *tfprotov5.DynamicValue for a *tfsdk.Resource. +func Resource(ctx context.Context, fw *tfsdk.Resource) (*tfprotov5.DynamicValue, diag.Diagnostics) { + if fw == nil { + return nil, nil + } + + data := &fwschemadata.Data{ + Description: fwschemadata.DataDescriptionResource, + Schema: fw.Schema, + TerraformValue: fw.Raw, + } + + return DynamicValue(ctx, data) +} diff --git a/internal/toproto6/list_resource_result.go b/internal/toproto6/list_resource_result.go new file mode 100644 index 000000000..a2945b897 --- /dev/null +++ b/internal/toproto6/list_resource_result.go @@ -0,0 +1,51 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package toproto6 + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" +) + +func ListResourceResult(ctx context.Context, result *fwserver.ListResult) tfprotov6.ListResourceResult { + allDiags := result.Diagnostics + if allDiags.HasError() { + return tfprotov6.ListResourceResult{ + Diagnostics: Diagnostics(ctx, allDiags), + } + } + + resourceIdentity, diags := ResourceIdentity(ctx, result.Identity) + allDiags.Append(diags...) + + return tfprotov6.ListResourceResult{ + DisplayName: result.DisplayName, + Identity: resourceIdentity, + Diagnostics: Diagnostics(ctx, allDiags), + } +} + +func ListResourceResultWithResource(ctx context.Context, result *fwserver.ListResult) tfprotov6.ListResourceResult { + allDiags := result.Diagnostics + if allDiags.HasError() { + return tfprotov6.ListResourceResult{ + Diagnostics: Diagnostics(ctx, allDiags), + } + } + + resourceIdentity, diags := ResourceIdentity(ctx, result.Identity) + allDiags.Append(diags...) + + resource, diags := Resource(ctx, result.Resource) + allDiags.Append(diags...) + + return tfprotov6.ListResourceResult{ + DisplayName: result.DisplayName, + Identity: resourceIdentity, + Resource: resource, + Diagnostics: Diagnostics(ctx, allDiags), + } +} diff --git a/internal/toproto6/listresourcemetadata.go b/internal/toproto6/listresourcemetadata.go new file mode 100644 index 000000000..6a6d8ab2d --- /dev/null +++ b/internal/toproto6/listresourcemetadata.go @@ -0,0 +1,19 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package toproto6 + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" +) + +// ListResourceMetadata returns the tfprotov6.ListResourceMetadata for a +// fwserver.ListResourceMetadata. +func ListResourceMetadata(ctx context.Context, fw fwserver.ListResourceMetadata) tfprotov6.ListResourceMetadata { + return tfprotov6.ListResourceMetadata{ + TypeName: fw.TypeName, + } +} diff --git a/internal/toproto6/resource.go b/internal/toproto6/resource.go new file mode 100644 index 000000000..2aa6e8dd5 --- /dev/null +++ b/internal/toproto6/resource.go @@ -0,0 +1,28 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package toproto6 + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschemadata" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" +) + +// Resource returns the *tfprotov6.DynamicValue for a *tfsdk.Resource. +func Resource(ctx context.Context, fw *tfsdk.Resource) (*tfprotov6.DynamicValue, diag.Diagnostics) { + if fw == nil { + return nil, nil + } + + data := &fwschemadata.Data{ + Description: fwschemadata.DataDescriptionResource, + Schema: fw.Schema, + TerraformValue: fw.Raw, + } + + return DynamicValue(ctx, data) +} diff --git a/list/list_resource.go b/list/list_resource.go index a5d36a533..8e793dc70 100644 --- a/list/list_resource.go +++ b/list/list_resource.go @@ -8,6 +8,7 @@ import ( "iter" "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/tfsdk" ) @@ -97,6 +98,9 @@ type ListRequest struct { // IncludeResource indicates whether the provider should populate the // [ListResult.Resource] field. IncludeResource bool + + ResourceSchema fwschema.Schema + ResourceIdentitySchema fwschema.Schema } // ListResultsStream represents a streaming response to a [ListRequest]. An @@ -122,7 +126,7 @@ var NoListResults = func(func(ListResult) bool) {} type ListResult struct { // Identity is the identity of the managed resource instance. // - // A nil value will raise will raise a diagnostic. + // A nil value will raise an error diagnostic. Identity *tfsdk.ResourceIdentity // Resource is the provider's representation of the attributes of the diff --git a/list/tosdk.go b/list/tosdk.go new file mode 100644 index 000000000..cabf10bf9 --- /dev/null +++ b/list/tosdk.go @@ -0,0 +1,50 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package list + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" +) + +func (r ListRequest) ToResource(ctx context.Context, val any) (*tfsdk.Resource, diag.Diagnostics) { + resource := &tfsdk.Resource{Schema: r.ResourceSchema} + diags := resource.Set(ctx, val) + return resource, diags +} + +func (r ListRequest) ToIdentity(ctx context.Context, val any) (*tfsdk.ResourceIdentity, diag.Diagnostics) { + identity := &tfsdk.ResourceIdentity{Schema: r.ResourceIdentitySchema} + diags := identity.Set(ctx, val) + + return identity, diags +} + +func (r ListRequest) ToListResult(ctx context.Context, identityVal any, resourceVal any, displayName string) ListResult { + allDiags := diag.Diagnostics{} + + identity, diags := r.ToIdentity(ctx, identityVal) + allDiags.Append(diags...) + if diags.HasError() { + return ListResult{Diagnostics: allDiags} + } + + var resource *tfsdk.Resource + if r.IncludeResource && resourceVal != nil { + resource, diags = r.ToResource(ctx, resourceVal) + allDiags.Append(diags...) + if diags.HasError() { + return ListResult{Diagnostics: allDiags} + } + } + + return ListResult{ + DisplayName: displayName, + Resource: resource, + Identity: identity, + Diagnostics: allDiags, + } +} diff --git a/tfsdk/resource.go b/tfsdk/resource.go index 49bc09b8e..fc6f485f6 100644 --- a/tfsdk/resource.go +++ b/tfsdk/resource.go @@ -20,8 +20,8 @@ type Resource struct { } // Get populates the struct passed as `target` with the resource. -func (c Resource) Get(ctx context.Context, target interface{}) diag.Diagnostics { - return c.data().Get(ctx, target) +func (r Resource) Get(ctx context.Context, target interface{}) diag.Diagnostics { + return r.data().Get(ctx, target) } // GetAttribute retrieves the attribute or block found at `path` and populates @@ -31,8 +31,8 @@ func (c Resource) Get(ctx context.Context, target interface{}) diag.Diagnostics // // Attributes or elements under null or unknown collections return null // values, however this behavior is not protected by compatibility promises. -func (c Resource) GetAttribute(ctx context.Context, path path.Path, target interface{}) diag.Diagnostics { - return c.data().GetAtPath(ctx, path, target) +func (r Resource) GetAttribute(ctx context.Context, path path.Path, target interface{}) diag.Diagnostics { + return r.data().GetAtPath(ctx, path, target) } // PathMatches returns all matching path.Paths from the given path.Expression. @@ -40,14 +40,30 @@ func (c Resource) GetAttribute(ctx context.Context, path path.Path, target inter // If a parent path is null or unknown, which would prevent a full expression // from matching, the parent path is returned rather than no match to prevent // false positives. -func (c Resource) PathMatches(ctx context.Context, pathExpr path.Expression) (path.Paths, diag.Diagnostics) { - return c.data().PathMatches(ctx, pathExpr) +func (r Resource) PathMatches(ctx context.Context, pathExpr path.Expression) (path.Paths, diag.Diagnostics) { + return r.data().PathMatches(ctx, pathExpr) } -func (c Resource) data() fwschemadata.Data { +// Set populates the entire resource using the supplied Go value. The value `val` +// should be a struct whose values have one of the attr.Value types. Each field +// must be tagged with the corresponding schema field. +func (r *Resource) Set(ctx context.Context, val interface{}) diag.Diagnostics { + data := r.data() + diags := data.Set(ctx, val) + + if diags.HasError() { + return diags + } + + r.Raw = data.TerraformValue + + return diags +} + +func (r Resource) data() fwschemadata.Data { return fwschemadata.Data{ - Description: fwschemadata.DataDescriptionConfiguration, - Schema: c.Schema, - TerraformValue: c.Raw, + Description: fwschemadata.DataDescriptionResource, + Schema: r.Schema, + TerraformValue: r.Raw, } } diff --git a/tfsdk/resource_identity.go b/tfsdk/resource_identity.go index fd9f30579..98737d6a3 100644 --- a/tfsdk/resource_identity.go +++ b/tfsdk/resource_identity.go @@ -5,6 +5,7 @@ package tfsdk import ( "context" + "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwschemadata"