How to model a Map with variable keys and differing value types

I’ve spent some time going through the docs but I stumped at present. Currently migrating a provider to the plugin framework from SDK V2.

The terraform config for this resource can look like this:

resource "my_resource" "resource_name" {
  app_id   = 223590
  settings = {
    name                        = "Aoo Name"
    hidden_group_ids   = "1, 2, 3"
    default_last_used    = false
  }
}

The settings configuration can have any number of fields with potentially any value type.

Previously in SDKV2 we defined the schema for settings as:

"settings": {
				Description: "Settings fields.",
				Type:        schema.TypeMap,
				Required:    true,
				Elem: &schema.Schema{
					Type: schema.TypeString,
				},
			},

and then we had methods to convert from the API/Terraform as follows:

func resourceApplicationInstallationFlattenSettings(settings client.ApplicationSettings) map[string]interface{} {
	attrs := make(map[string]interface{})
	for key, value := range settings {
		if value != nil {
			//Exclude title as it's not something we can terraform but comes back in the settings response
			if key != "title" {
				attrs[key] = fmt.Sprintf("%v", value)
			}
		}
	}
	return attrs
}
func resourceApplicationInstallationExpandConfigurationAttributes(settingsAttributes map[string]interface{}) map[string]interface{} {
	result := make(map[string]interface{})
	for key, value := range settingsAttributes {
		if boolVal, err := strconv.ParseBool(value.(string)); err == nil {
			result[key] = boolVal
		} else {
			result[key] = value
		}
	}
	return result
}

Note we only actually worry about booleans and strings but potentially we might one day see other types. We also just set everything to a String when sending back to the Schema model (not sure if there’s a better way to do this but not too worried with it being the code we are migrating away from).

In the framework plugin migration we’ve defined the schema as:

"settings": schema.MapAttribute{
				ElementType: types.StringType,
				Required:    true,
				Description: "Setting fields.",
			},

Then we are trying to do similar to determine whether the field is boolean/string as follows:

settings := make(map[string]interface{}, len(data.Settings.Elements()))

	for key, value := range data.Settings.Elements() {
		if boolVal, err := strconv.ParseBool(value.String()); err == nil {
			settings[key] = boolVal
		} else {
			settings[key] = value
		}
	}

value.String() seems to always return multiple sets of quotes but the ValueString() method is not available - I assume that is due to the use of Elements() here?

In addition to this we were trying to write tests to test our conversion method but couldn’t work out how to manually create the schema representation. We tried this:

elements := map[string]attr.Value{
		"name":      types.StringValue("app name"),
		"some-bool": types.BoolValue(false),
	}

	settings, _ := types.MapValue(types.StringType, elements)

	schemaModelAppInstallation := ApplicationInstallationResourceModel{
		Id:       types.StringValue("12345"),
		AppId:    types.Int64Value(999),
		Enabled:  types.BoolValue(true),
		Settings: settings,
	}

but the MapValue call fails due to the fact it expects types.StringType. How would we use the types.ObjectType here to copy all values into the map?

Sorry for the long post but I’ve been struggling with this today so if anyone else has tackled this I would greatly appreciate any advice you can offer.

Thanks

Paul

Hey there @PaulW :wave: , thanks for the post and sorry you’re running into trouble here.

So when you define an attribute as a schema.MapAttribute with types.StringType, you’re describing to Terraform that all values in that map should be treated like a string.

This means, under the scenes, Terraform will convert the values of your map from the terraform config into a string if possible (erroring if not possible), and it will also store all values into state as strings.

Example

func (r *thingResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
	resp.Schema = schema.Schema{
		Attributes: map[string]schema.Attribute{
			"settings": schema.MapAttribute{
				ElementType: types.StringType,
				Required:    true,
				Description: "Setting fields.",
			},
		},
	}
}
resource "examplecloud_thing" "this" {
  settings = {
    string_val = "hey"

    // These will be coerced into a string by Terraform
    // before it gets to your provider.
    bool_val = true
    num_val  = 1.234
  }
}

Since we now know that Terraform will be sending these values as string types to your map, retrieving these values could look like this:

// Extract the map elements from Terraform, which all values will be `types.String`
elements := make(map[string]types.String, len(data.Settings.Elements()))
resp.Diagnostics.Append(data.Settings.ElementsAs(ctx, &elements, false)...)
if resp.Diagnostics.HasError() {
	return
}

// We can get the actual value using `(types.String).ValueString()` method
settings := make(map[string]any, len(elements))
for key, val := range elements {
	if boolVal, err := strconv.ParseBool(val.ValueString()); err == nil {
		// It's a bool!
		settings[key] = boolVal
	} else {
		// It's anything else!
		settings[key] = val.ValueString()
	}
}

As you mentioned, we need to use the (types.String).ValueString method and not the String() method, the latter returns the string representation for messaging purposes (hence the quotes). You were running into the problem that the (types.Map).Elements method returns the non-type specific attr.Value type.

After running the code above you’d get something in your map[string]any like this:

image

And the resulting state should look like:

image

As for constructing the model, just like Terraform, you’ll need to coerce the values into strings like below:

// Using `MapValueMust` for example purposes :)
settingsMap := types.MapValueMust(types.StringType, map[string]attr.Value{
	"string_val": types.StringValue("hey"),
	"bool_val":   types.StringValue("true"),
	"num_val":    types.StringValue("1.234"),
})

Hopefully all that makes sense! If you haven’t seen it, check out our documentation about the Map type: Plugin Development - Framework: Map Type | Terraform | HashiCorp Developer

Just another example of the converting that Terraform does, you’ll get an error if you try to use the above schema with something that can’t be converted to a string:

resource "examplecloud_thing" "this" {
  settings = {
    string_val = "hey"
    bool_val = true
    num_val  = 1.234
    bad_val = {
      oh_boy = "this won't work"
    }
  }
}
 $ terraform apply -auto-approve
╷
│ Error: Incorrect attribute value type
│ 
│   on resource.tf line 9, in resource "examplecloud_thing" "this":
│    9:   settings = {
│   10:     string_val = "hey"
│   12:     bool_val = true
│   13:     num_val  = 1.234
│   15:     bad_val = {
│   16:       oh_boy = "this won't work"
│   17:     }
│   18:   }
│ 
│ Inappropriate value for attribute "settings": element "bad_val": string required.

Hi @austin.valle

Thank you for this - the key part I was missing was converting the data elements to a map of types.String values first which as you say was causing this issue:

You were running into the problem that the (types.Map).Elements method returns the non-type specific attr.Value type

This is really helpful so thank you again, much appreciated!

Paul

1 Like