hashicorp / hashicorp/terraform-plugin-codegen-framework

Generate "expand" and "flatten" functions for associated external types

Open
#31 3 comments 1 reaction 0 assignees View on GitHub
Dominant language
Go
Stars
57
Forks
29
Avg merge
2d 13h
Merged PRs (30d)
1

Description

### Background

Given that Go uses static typing, when encountering “objects” with Go code it is very common that the API SDK (or other external code) implementation will use custom Go structure types for those objects. For example, a Terraform schema may need to describe an API with a list nested attribute (`types.List` of `types.Object`), whereas the API SDK may implement an array/slice of a custom Go type. The provider developer is responsible for converting to and from the Terraform SDK types to the external types to properly generate API requests (`Create`, `Update`, `Delete`) or handle API responses (`Read`). This data handling logic can be cumbersome and it is almost exclusively repetitive depending on the API. Previously with `terraform-plugin-sdk`, this type of provider logic was conventionally put into what developers called “expand” (Terraform to API) and “flatten” (API to Terraform) functions.

### Proposal

To ease developer burden, it is proposed that the [associated_external_type](https://github.com/hashicorp/terraform-plugin-codegen-spec/blob/main/spec/schema.json#L321), which can be optionally specified, be used in the generation of "expand" and "flatten" functions.

The following intermediate representation illustrates the usage of `associated_external_type` for a single nested block:

```json
{
"resources": [
{
"name": "aws_imagebuilder_image",
"schema": {
"blocks": [
{
"name": "image_tests_configuration",
"single_nested": {
"associated_external_type": {
"imports": [
{
"path": "github.com/aws/aws-sdk-go/service/imagebuilder"
}
],
"type": "*imagebuilder.ImageTestsConfiguration",
"mapping": {
"timeout_minutes": {
"name": "MinutesTimeout",
"type": "string"
}
}
},
"attributes": [
{
"name": "image_tests_enabled",
"bool": {
"computed_optional_required": "optional"
}
},
{
"name": "timeout_minutes",
"int64": {
"computed_optional_required": "computed_optional"
}
}
]
}
}
]
}
}
],
}
```

In addition to the schema, models and model helper functions that are generated from the IR, the code generation would also create the following "expand" and "flatten" functions:

```go
func (m ImageTestsConfigurationModel) ToImageTestsConfiguration(ctx context.Context, tfObject types.Object) (*imagebuilder.ImageTestsConfiguration, diag.Diagnostics) {
var diags diag.Diagnostics

if tfObject.IsNull() || tfObject.IsUnknown() {
return nil, diags
}

var tfModel ImageTestsConfigurationModel

diags.Append(tfObject.As(ctx, &tfModel, basetypes.ObjectAsOptions{})...)

if diags.HasError() {
return nil, diags
}

apiObject := &imagebuilder.ImageTestsConfiguration{
ImageTestsEnabled: tfModel.ImageTestsEnabled.ValueBoolPointer(),
TimeoutMinutes: tfModel.TimeoutMinutes.ValueInt64Pointer(),
}

return apiObject, diags
}

func (m ImageTestsConfigurationModel) FromImageTestsConfiguration(ctx context.Context, apiObject *imagebuilder.ImageTestsConfiguration) (types.Object, diag.Diagnostics) {
var diags diag.Diagnostics
var tfModel ImageTestsConfigurationModel

if apiObject == nil {
return m.objectNull(ctx), diags
}

tfModel.ImageTestsEnabled = types.BoolPointerValue(apiObject.ImageTestsEnabled)
tfModel.TimeoutMinutes = types.Int64PointerValue(apiObject.TimeoutMinutes)

return m.objectValueFrom(ctx)
}
```

### "Implicit" Mapping
The only fields that are currently defined within the schema for [associated_external_type](https://github.com/hashicorp/terraform-plugin-codegen-spec/blob/main/spec/schema.json#L321) are `type` and `import`. Consequently, assumptions have to be made about how fields within an intermediate representation (IR) that have an _associated_external_type_ defined should be handled when "expand" and "flatten" functions are generated.

#### Primitives

Primitives are defined as the following types:

| Schema Attribute Type | Model Type |
| ------------- | ------------- |
| schema.BoolAttribute | types.Bool, basetypes.BoolValue |
| schema.Float64Attribute | types.Float64, basetypes.Float64Value |
| schema.Int64Attribute | types.Int64, basetypes.Int64Value |
| schema.NumberAttribute | types.Number, basetypes.NumberValue |
| schema.StringAttribute | types.String, basetypes.StringValue |

If the IR contains an _associated_external_type_ for a primitive, the "expand" and "flatten" functions illustrated below will be generated.

Note that this assumes that the `associated_external_type.type` (e.g., `*apisdk.BoolType`) is something like the following:

```go
type BoolType *bool
```

**expand**

```go
func To(ctx context.Context, tfType types.) (*, diag.Diagnostics) {
var diags diag.Diagnostics

if tfType.IsNull() || tfType.IsUnknown() {
return nil, diags
}

var m

m = tfType.Value()

return &m, diags
}
```

**flatten**

```go
func From(ctx context.Context, apiObject *) (types., diag.Diagnostics) {
var diags diag.Diagnostics

if apiObject == nil {
return types.Null(), diags
}

m := types.(*apiObject)

return m, diags
}
```

The following assumptions have been made when implicitly mapping primitives:

- The _associated_external_type.type_ is a type which can be assigned a pointer of the field value type (i.e., types.Bool | basetypes.BoolValue <=> *bool etc).
- The type specified for the _associated_external_type.type_ in the IR will be a pointer.
- The usage of a pointer or reference in the "To<...>" and "From<...>" functions is used to emphasise where each is being used.

#### Collections

Collections are defined as the following types:

| Schema Attribute Type | Model Type |
| ------------- | ------------- |
| schema.ListAttribute | types.List, basetypes.ListValue |
| schema.MapAttribute | types.Map, basetypes.MapValue |
| schema.SetAttribute | types.Set, basetypes.SetValue |

If the IR contains an _associated_external_type_ for a collection, the "expand" and "flatten" functions illustrated below will be generated.

Note that this assumes that the `associated_external_type.type` (e.g., `*apisdk.BoolSliceType`) is something like the following:

```go
type BoolSliceType []*bool
```

**expand**

```go
func To(ctx context.Context, tfType types.) (*, diag.Diagnostics) {
var diags diag.Diagnostics

if tfType.IsNull() || tfType.IsUnknown() {
return nil, diags
}

var m

// ElementsAs() converts correctly from types.ListType{ElemType: types.BoolType}
// to BoolSliceType (i.e., []*bool), for instance.
diags.Append(tfType.ElementsAs(ctx, &m, false)...)

if diags.HasError() {
return nil, diags
}

return &m, diags
}
```

**flatten**

```go
func From(ctx context.Context, apiObject *) (types., diag.Diagnostics) {
var diags diag.Diagnostics

if apiObject == nil {
// The attr.Type required to call Null() can be obtained from
// schema.Attribute ElementType.
return types.Null(types.), diags
}

m, d := types.ValueFrom(ctx, types., apiObject)

diags.Append(d...)

if diags.HasError() {
return types.Null(types.), diags
}

return m, diags
}
```

The following assumptions have been made when implicitly mapping collections:

- The _associated_external_type.type_ is a type which can be passed to `ElementsAs(...)`. For example, if the schema defines the list attribute as `schema.ListAttribute{ElementType: types.BoolType}` then _associated_external_type.type_ would need to be a type that can be assigned `[]*bool`.

#### Object

Object is defined as the following type:

| Schema Attribute Type | Model Type |
| ------------- | ------------- |
| schema.ObjectAttribute | types.Object, basetypes.ObjectValue |

If the IR contains an _associated_external_type_ for an object, the "expand" and "flatten" functions listed below will be generated.

The "expand" and "flatten" functions are generated on the basis of `schema.ObjectAttribute` and `ApiObject` having the following form:

```go
schema.ObjectAttribute{
AttributeTypes: map[string]attr.Type{
"bool_field": types.BoolType,
"float64_field": types.Float64Type,
"list_field": types.ListType{
ElemType: types.StringType,
},
},
/* ... */
},
```

```go
type ApiObject struct {
BoolField *bool
Int64Field *int64
ListField []*string
}
```

**expand**

```go
func To(ctx context.Context, tfObject types.Object) (*, diag.Diagnostics) {
var diags diag.Diagnostics

if tfObject.IsNull() || tfObject.IsUnknown() {
return nil, diags
}

// Iterate over map[string]attr.Type from schema and generate
// struct as there won't be a corresponding model as they're
// only generated for list, map, set, single nested attributes/blocks.
type objStruct struct {
BoolField types.Bool `tfsdk:"bool_field"`
Float64Field types.Float64 `tfsdk:"float64_field"`
ListField types.List `tfsdk:"list_field"`
}

var objS objStruct

diags.Append(tfObject.As(ctx, &objS, basetypes.ObjectAsOptions{})...)

if diags.HasError() {
return nil, diags
}

// Generate on basis of corresponding field in AttributeTypes from schema.
// Need to detect if handling any non-primitive (i.e., list, map, object, or set).
var listField []*string

d := objS.ListField.ElementsAs(ctx, &listField, false)

diags.Append(d...)

if diags.HasError() {
return nil, diags
}

apiObject := &ApiObject{
BoolField: objS.BoolField.ValueBoolPointer(),
Float64Field: objS.Float64Field.ValueFloat64Pointer(),
ListField: listField,
}

return apiObject, diags
}
```

**flatten**

```go
func From(ctx context.Context, apiObject *ApiObject) (types.Object, diag.Diagnostics) {
var diags diag.Diagnostics

attrTypes := map[string]attr.Type{
"BoolField": types.BoolType,
"Float64Field": types.Float64Type,
"ListField": types.ListType{
ElemType: types.StringType,
},
}

if apiObject == nil {
return types.ObjectNull(attrTypes), diags
}

// Generate on basis of corresponding field in AttributeTypes from schema.
// Need to detect if handling any non-primitive (i.e., list, map, object, or set).
l, d := types.ListValueFrom(ctx, types.StringType, apiObject.ListField)

diags.Append(d...)

if diags.HasError() {
return types.ObjectNull(attrTypes), diags
}

o, d := types.ObjectValue(
attrTypes,
map[string]attr.Value{
"BoolField": types.BoolPointerValue(apiObject.BoolField),
"Float64Field": types.Float64PointerValue(apiObject.Float64Field),
"ListField": l,
},
)

diags.Append(d...)

return o, d
}
```

The following assumptions have been made when implicitly mapping collections:

- The _associated_external_type.type_ contains a one-to-one mapping of field names and types (e.g., if the `schema.ObjectAttribute` contains within `AttributeTypes` a `"BoolAttribute": types.BoolType` the expectation is that the `ApiObject` will contain a field called `BoolAttribute` which holds `*bool`).

#### Nested Attributes and Blocks

Nested attributes and blocks are defined as the following types:

| Schema Attribute Type | Model Type |
| ------------- | ------------- |
| schema.ListNestedAttribute | types.List, basetypes.ListValue |
| schema.ListNestedBlock | types.List, basetypes.ListValue |
| schema.MapNestedAttribute | types.Map, basetypes.MapValue |
| schema.SetNestedAttribute | types.Set, basetypes.SetValue |
| schema.SetNestedBlock | types.Set, basetypes.SetValue |
| schema.SingleNestedAttribute | types.Object, basetypes.ObjectValue |
| schema.SingleNestedBlock | types.Object, basetypes.ObjectValue |

##### Single Nested Attribute without nested _associated_external_type_

If the IR contains an _associated_external_type_ defined for a single nested attribute with no _associated_external_type_(s) defined on any of the attributes within the single nested attribute, then the "expand" and "flatten" functions listed below will be generated.

The "expand" and "flatten" functions are generated on the basis of `schema.SingleNestedAttribute`, and `ApiSingleNestedAttribute` having the following form:

```go
schema.SingleNestedAttribute{
Attributes: map[string]schema.Attribute{
"bool_attribute": schema.BoolAttribute{
Optional: true,
},
"int64_attribute": schema.Int64Attribute{
Optional: true,
},
},
/* ... */
},
```

```go
type ApiSingleNestedAttribute struct {
BoolAttribute *bool
Int64Attribute *int64
}
```

**expand**

```go
func ToSingleNestedAttribute(ctx context.Context, tfObject types.Object) (*ApiSingleNestedAttribute, diag.Diagnostics) {
var diags diag.Diagnostics

if tfObject.IsNull() || tfObject.IsUnknown() {
return nil, diags
}

var sna SingleNestedAttributeModel

diags.Append(tfObject.As(ctx, &sna, basetypes.ObjectAsOptions{})...)

if diags.HasError() {
return nil, diags
}

apiObject := &ApiSingleNestedAttribute{
BoolAttribute: sna.BoolAttribute.ValueBoolPointer(),
Int64Attribute: sna.Int64Attribute.ValueInt64Pointer(),
}

return apiObject, diags
}
```

**flatten**

```go
func FromSingleNestedAttribute(ctx context.Context, apiObject *ApiSingleNestedAttribute) (types.Object, diag.Diagnostics) {
var diags diag.Diagnostics
var sna SingleNestedAttributeModel

if apiObject == nil {
// ObjectNull() is pre-generated for nested attributes.
return sna.ObjectNull(ctx), diags
}

sna.BoolAttribute = types.BoolPointerValue(apiObject.BoolAttribute)
sna.Int64Attribute = types.Int64PointerValue(apiObject.Int64Attribute)

// ObjectValueFrom() is pre-generated for nested attributes.
return sna.ObjectValueFrom(ctx, sna)
}
```

##### Single Nested Attribute with nested _associated_external_type_(s)

If the IR contains an _associated_external_type_ defined for a single nested attribute with _associated_external_type_(s) defined on the attributes within the single nested attribute, then the "expand" and "flatten" functions listed below will be generated.

The "expand" and "flatten" functions are generated on the basis of `schema.SingleNestedAttribute`, and `ApiSingleNestedAttribute` having the following form:

```go
schema.SingleNestedAttribute{
Attributes: map[string]schema.Attribute{
"bool_attribute": schema.BoolAttribute{
Optional: true,
},
"int64_attribute": schema.Int64Attribute{
Optional: true,
},
},
/* ... */
},
```

```go
type ApiSingleNestedAttribute struct {
BoolAttribute *ApiBoolAttribute
Int64Attribute *int64
}

type ApiBoolAttribute *bool
```

**expand**

```go
func ToSingleNestedAttribute(ctx context.Context, tfObject types.Object) (*ApiSingleNestedAttribute, diag.Diagnostics) {
var diags diag.Diagnostics

if tfObject.IsNull() || tfObject.IsUnknown() {
return nil, diags
}

var sna SingleNestedAttributeModel

diags.Append(tfObject.As(ctx, &sna, basetypes.ObjectAsOptions{})...)

if diags.HasError() {
return nil, diags
}

toBoolAttribute, d := ToBoolAttribute(ctx, sna.BoolAttribute)

diags.Append(d...)

if diags.HasError() {
return nil, diags
}

apiObject := &ApiSingleNestedAttribute{
BoolAttribute: toBoolAttribute,
Int64Attribute: sna.Int64Attribute.ValueInt64Pointer(),
}

return apiObject, diags
}

func ToBoolAttribute(ctx context.Context, tfType types.Bool) (*ApiBoolAttribute, diag.Diagnostics) {
var diags diag.Diagnostics

if tfType.IsNull() || tfType.IsUnknown() {
return nil, diags
}

var m ApiBoolAttribute

m = tfType.ValueBoolPointer()

return &m, diags
}
```

**flatten**

```go
func FromSingleNestedAttribute(ctx context.Context, apiObject *ApiSingleNestedAttribute) (types.Object, diag.Diagnostics) {
var diags diag.Diagnostics
var sna SingleNestedAttributeModel

if apiObject == nil {
return sna.ObjectNull(ctx), diags
}

fromBoolAttribute, d := FromBoolAttribute(ctx, apiObject.BoolAttribute)

diags.Append(d...)

if diags.HasError() {
return sna.ObjectNull(ctx), diags
}

sna.BoolAttribute = fromBoolAttribute
sna.Int64Attribute = types.Int64PointerValue(apiObject.Int64Attribute)

return sna.ObjectValueFrom(ctx, sna)
}

func FromBoolAttribute(ctx context.Context, apiObject *ApiBoolAttribute) (types.Bool, diag.Diagnostics) {
var diags diag.Diagnostics

if apiObject == nil {
return types.BoolNull(), diags
}

b := types.BoolPointerValue(*apiObject)

return b, diags
}
```

### Further Considerations

- ~~Currently the [IR Schema](https://github.com/hashicorp/terraform-plugin-codegen-spec/blob/main/spec/schema.json) only permits the usage of `associated_external_type` on data source, provider and resource single nested attributes. This will need to be extended to all attribute and block types.~~
- [Add associated_external_type to all attributes and blocks](https://github.com/hashicorp/terraform-plugin-codegen-spec/pull/37#top)
- The [associated_external_type](https://github.com/hashicorp/terraform-plugin-codegen-spec/blob/main/spec/schema.json#L321) as it is currently defined allows specifying `type` and `import`. Consequently some assumptions will need to be made about how the fields within the model struct are mapped to/from the fields in the "object" that is used for interaction with the API. An initial assumption would be that there is a **direct one-to-one mapping** unless/until a "mapping" field is defined for `associated_external_type` which allows for specifying how this translation from model to API object should happen.
- [Consider adding "mapping" to associated_external_type](https://github.com/hashicorp/terraform-plugin-codegen-spec/issues/38#top)

Contributor guide

No contributing guide indexed for this repository

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.