How to set `types.Set` from Read method

Hello,

I’m trying to migrate an existing provider from v2 SDK to the new framework but am confused how to set a model field (Domain, assigned the type types.Set) from the Read method of the resource.

The schema looks like this:

		Blocks: map[string]schema.Block{
			"domain": schema.SetNestedBlock{
				NestedObject: schema.NestedBlockObject{
					Attributes: map[string]schema.Attribute{
						"comment": schema.StringAttribute{
							MarkdownDescription: "An optional comment about the domain",
							Optional:            true,
						},
						"name": schema.StringAttribute{
							MarkdownDescription: "The domain that this service will respond to. It is important to note that changing this attribute will delete and recreate the resource",
							Required:            true,
						},
					},
				},
			},
		},

Effectively the structure of the ‘block’ is like a map where a ‘domain’ consists of a ‘comment’ and a ‘name’

So something like the following provider code doesn’t make sense to me because it looks like I’m only storing values in the set (where I think I should be storing a key/value, e.g. comment=“foo” and name=“bar”):

domains := []attr.Value{}
for _, domain := range clientDomainResp {
	if v, ok := domain.GetCommentOk(); ok {
		domains = append(domains, NewStringValue(*v),)
	}
	if v, ok := domain.GetNameOk(); ok {
		domains = append(domains, NewStringValue(*v),)
	}
}
set, _ := types.SetValue(types.StringType, domains)
data.Domain = set

UPDATE: Looks like (from a test error I’m seeing) that I need to assign a tftypes.Set[tftypes.Object["comment":tftypes.String, "name":tftypes.String]]. Again, I’m not sure how to do this :frowning_face:

I’ve really struggled with this, so hopefully someone can help clarify for me.

Thanks!

Hi @Integralist :wave:

Using the schema you supplied, and the following Terraform configuration:

resource "example_resource" "example" {
  domain {
    #    comment = "string comment"
    name = "string name"
  }
  domain {
    comment = "string comment"
    name    = "string name"
  }
}

You can use something along the following lines:

type exampleResourceData struct {
	Domain types.Set `tfsdk:"domain"`
}

func (e *exampleResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
	var data exampleResourceData

	diags := req.Plan.Get(ctx, &data)
	resp.Diagnostics.Append(diags...)

	if resp.Diagnostics.HasError() {
		return
	}

	diags = resp.State.Set(ctx, &data)
	resp.Diagnostics.Append(diags...)
}

If you have the option to use nested attributes rather than nested blocks then I would recommend making the switch but it would involved a breaking change from the standpoint of practitioner usage of your provider as the syntax used in Terraform configuration would change.

Nested block syntax:

resource "example_resource" "example" {
  domain {
    #    comment = "string comment"
    name = "string name"
  }
  domain {
    comment = "string comment"
    name    = "string name"
  }
}

Nested attribute syntax:

resource "example_resource" "example" {
  domain = {
    #    comment = "string comment"
    name = "string name"
  }
  domain = {
    comment = "string comment"
    name    = "string name"
  }
}

Thanks.

I’m using this code already in the Create method, but the trouble I’m having is in the Read.

In Read you have to get the data from the prior state (initially)…

resp.Diagnostics.Append(req.State.Get(ctx, &data)...)

…before then calling your API for the latest data to populate the state with if there’s anything new/different.

Also, in the GitHub - hashicorp/terraform-provider-scaffolding-framework: Quick start repository for creating a Terraform provider using terraform-plugin-framework repo it provides a test case for verifying importing a resource, and in this scenario there is no prior state and so this is another reason Read needs to call the provider’s API to get the latest version of the data and transform it into a format that can be loaded into the resource data model.

This is my Read method, but it doesn’t work because I can’t construct the right data type to populate my Domain field…

func (r *ServiceVCLResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
	var data *ServiceVCLResourceModel

	// Read Terraform prior state data into the model
	resp.Diagnostics.Append(req.State.Get(ctx, &data)...)

	if resp.Diagnostics.HasError() {
		return
	}

	clientReq := r.client.ServiceAPI.GetServiceDetail(r.clientCtx, data.ID.ValueString())
	clientResp, httpResp, err := clientReq.Execute()
	if err != nil {
		tflog.Trace(ctx, "Fastly ServiceAPI.GetServiceDetail error", map[string]any{"http_resp": httpResp})
		resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to retrieve service details, got error: %s", err))
		return
	}

	data.Comment = types.StringValue(clientResp.GetComment())
	data.ID = types.StringValue(clientResp.GetID())
	data.Name = types.StringValue(clientResp.GetName())

	clientDomainReq := r.client.DomainAPI.ListDomains(r.clientCtx, clientResp.GetID(), 1)

	clientDomainResp, httpResp, err := clientDomainReq.Execute()
	if err != nil {
		tflog.Trace(ctx, "Fastly DomainAPI.ListDomains error", map[string]any{"http_resp": httpResp})
		resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to list domains, got error: %s", err))
		return
	}
	if httpResp.StatusCode != http.StatusOK {
		tflog.Trace(ctx, "Fastly API error", map[string]any{"http_resp": httpResp})
		resp.Diagnostics.AddError("API Error", fmt.Sprintf("Unsuccessful status code: %s", httpResp.Status))
		return
	}

	domains := []tftypes.Object{}
	for _, domain := range clientDomainResp {
		o := tftypes.Object{
			AttributeTypes: map[string]tftypes.Type{},
		}
		if v, ok := domain.GetCommentOk(); ok && *v != "" {
			o.AttributeTypes["comment"] = types.String(*v) // COMPILER ERROR: cannot convert *v (variable of type string) to basetypes.StringValue
		}
		if v, ok := domain.GetNameOk(); ok && *v != "" {
			o.AttributeTypes["name"] = types.String(*v) // COMPILER ERROR: cannot convert *v (variable of type string) to basetypes.StringValue
                                                  // Also tried `basetypes.NewStringValue(*v)` but it does implement full interface.
		}
		domains = append(domains, o)
	}
	fmt.Printf("domains: %+v\n", domains)

	set, _ := types.SetValueFrom(ctx, basetypes.StringType{}, domains)
	data.Domain = set
	fmt.Printf("data: %+v\n", data)

	// Save updated data into Terraform state
	resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}

I also tried:

	domains := []map[string]string{}
	for _, domain := range clientDomainResp {
		m := make(map[string]string)
		if v, ok := domain.GetCommentOk(); ok && *v != "" {
			m["comment"] = *v
		}
		if v, ok := domain.GetNameOk(); ok && *v != "" {
			m["name"] = *v
		}
		domains = append(domains, m)
	}

	set, diag := types.SetValueFrom(ctx, types.ObjectType{}, domains)
	data.Domain = set

But the diag reported…

cannot use type map[string]string as schema type basetypes.ObjectType; basetypes.ObjectType must be an attr.TypeWithElementType to hold map[string]string summary:Value Conversion Error}

Hi @Integralist,

Apologies, I missed that you were referring to the Read method.

You should be able to mutate the state in the Read by using something like the following:

func (e *exampleResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
	var data exampleResourceData

	diags := req.State.Get(ctx, &data)
	resp.Diagnostics.Append(diags...)

	if resp.Diagnostics.HasError() {
		return
	}

	newData := exampleResourceData{
		Domain: types.SetValueMust(
			types.ObjectType{
				AttrTypes: map[string]attr.Type{
					"comment": types.StringType,
					"name":    types.StringType,
				},
			},
			[]attr.Value{
				types.ObjectValueMust(
					map[string]attr.Type{
						"comment": types.StringType,
						"name":    types.StringType,
					},
					map[string]attr.Value{
						"comment": types.StringValue("string comment modified"),
						"name":    types.StringValue("string name"),
					},
				),
			},
		),
	}

	diags = resp.State.Set(ctx, &newData)
	resp.Diagnostics.Append(diags...)
}

But it’s also worth looking at the tutorials to see how this can be achieved using data models.

Thanks!

I presume by “data models” you mean the use of a struct with nested tfsdk tags? If so is that only possible if I switch from a nested ‘block’ to a nested ‘attribute’? I’m guessing I can’t use that approach with a nested block (hence this more complicated code)?

I’ve been using a nested ‘block’ due to the fact that I wanted to limit the impact radius of a breaking interface change.

@bendbennett So one last question (your implementation appears to work, thanks for that!) but I’m seeing a test error that doesn’t make sense and Googling -> null doesn’t yield anything…

    service_vcl_resource_test.go:14: Step 1/3 error: After applying this test step and performing a `terraform refresh`, the plan was not empty.
        stdout
                                                                                                                                                                                                      
                                                                                                                                                                                                      
        Terraform used the selected providers to generate the following execution
        plan. Resource actions are indicated with the following symbols:
          ~ update in-place
                                                                                                                                                                                                      
        Terraform will perform the following actions:
                                                                                                                                                                                                      
          # fastly_service_vcl.test will be updated in-place
          ~ resource "fastly_service_vcl" "test" {
                id       = "<REDACTED>"
                name     = "tf-test-q2smgkojz2"
                # (3 unchanged attributes hidden)
                                                                                                                                                                                                      
              - domain {
                  - name = "tf-test-q2smgkojz2-terraform-provider-fastly-framework.integralist.co.uk" -> null
                }
              + domain {
                  + name = "tf-test-q2smgkojz2-terraform-provider-fastly-framework.integralist.co.uk"
                }
            }

As far as I can tell the domain ‘name’ value is the same and yet the plan suggests something is different and the only thing I’ve noticed is -> null ?

I think what’s happening here is that the prior state when marshalled into my data model is showing "comment":<null> but when I look at the same field after assigning my domain data it’s "comment":"".

I tried to assign a types.StringNull() to see if that would assign <null> but it still shows as an empty string. I presume this is because the type for that field is set to types.StringType.

@Integralist I can reproduce what you’re seeing with the following TF configuration, schema and CRUD functions.

Terraform Configuration

resource "example_resource" "example" {
  domain {
    name    = "string name"
  }
}

Schema

func (e *exampleResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
	resp.Schema = schema.Schema{
		Blocks: map[string]schema.Block{
			"domain": schema.SetNestedBlock{
				NestedObject: schema.NestedBlockObject{
					Attributes: map[string]schema.Attribute{
						"comment": schema.StringAttribute{
							MarkdownDescription: "An optional comment about the domain",
							Optional:            true,
						},
						"name": schema.StringAttribute{
							MarkdownDescription: "The domain that this service will respond to. It is important to note that changing this attribute will delete and recreate the resource",
							Required:            true,
						},
					},
				},
			},
		},
	}
}

Data Model

type exampleResourceData struct {
	Domain types.Set `tfsdk:"domain"`
}

Create

func (e *exampleResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
	var data exampleResourceData

	diags := req.Plan.Get(ctx, &data)
	resp.Diagnostics.Append(diags...)

	if resp.Diagnostics.HasError() {
		return
	}

	diags = resp.State.Set(ctx, &data)
	resp.Diagnostics.Append(diags...)
}

Read

func (e *exampleResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
	var data exampleResourceData

	diags := req.State.Get(ctx, &data)
	resp.Diagnostics.Append(diags...)

	if resp.Diagnostics.HasError() {
		return
	}

	newData := exampleResourceData{
		Domain: types.SetValueMust(
			types.ObjectType{
				AttrTypes: map[string]attr.Type{
					"comment": types.StringType,
					"name":    types.StringType,
				},
			},
			[]attr.Value{
				types.ObjectValueMust(
					map[string]attr.Type{
						"comment": types.StringType,
						"name":    types.StringType,
					},
					map[string]attr.Value{
						"comment": types.StringValue(""),
						"name":    types.StringValue("string name"),
					},
				),
			},
		),
	}

	diags = resp.State.Set(ctx, &newData)
	resp.Diagnostics.Append(diags...)
}

Update

func (e *exampleResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
	var data exampleResourceData

	diags := req.Plan.Get(ctx, &data)
	resp.Diagnostics.Append(diags...)

	if resp.Diagnostics.HasError() {
		return
	}

	diags = resp.State.Set(ctx, &data)
	resp.Diagnostics.Append(diags...)
}

Running terraform apply, terraform refresh, then terraform plan I see the following:

Terraform will perform the following actions:

  # example_resource.example will be updated in-place
  ~ resource "example_resource" "example" {
      - domain {
          - name = "string name" -> null
        }
      + domain {
          + name = "string name"
        }
    }

Plan: 0 to add, 1 to change, 0 to destroy.

This is a consequence of setting "comment" to types.StringValue("") in the Read function. I’m wondering if something similar is happening in your Read function?

You can avoid the issue by setting "comment" to types.StringNull() in the Read function.

Thanks @bendbennett for digging in and replicating. As I mentioned previously (see above) I had tried to use StringNull() but it was still producing an empty string. Well, it turns out that I had a logic bug that meant I was skipping over the StringNull() and still calling StringValue().

So I finally managed to get the tests passing. Thanks for your help (and patience)!

That’s great to hear. Thanks for letting me know and I’m glad I could be of some help.

1 Like

@bendbennett - There isn’t a way to handle NestedAttributeObject as predefined structs?

Hi @OrNovo :wave: You might have better luck/responses by opening a new topic with a full description of your question or problem statement.

1 Like