Skip to content

chore: Support nested attributes in conversion logic for generating Resource Model from API response #3261

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
7b6853e
failing TestUnmarshalNestedAllTypes
lantoli Apr 8, 2025
0e6d4b6
keepKeyCase doc
lantoli Apr 9, 2025
39c5113
marshal bool
lantoli Apr 9, 2025
da07fcb
rename jsonResp
lantoli Apr 10, 2025
91d0a8f
simple case test pass for case map[string]any
lantoli Apr 10, 2025
e664b47
rename model struct
lantoli Apr 10, 2025
e0509ca
skip attributes that are not in the model
lantoli Apr 10, 2025
f62a639
test case: model attr types in objects must match JSON types
lantoli Apr 10, 2025
879b454
pass test case: model attr types in objects must match JSON types
lantoli Apr 10, 2025
a8b0c45
add float and bool to modelTest
lantoli Apr 10, 2025
f8253dc
unkown and null attributes in model to get unmarshalled into
lantoli Apr 10, 2025
123d080
compare model instead of individual attrs
lantoli Apr 10, 2025
8460091
support null object as model source
lantoli Apr 11, 2025
a7a35ba
convertUnknownToNull
lantoli Apr 11, 2025
f6cca03
refactor getObjAttrsAndTypes and getNullAttr
lantoli Apr 11, 2025
1e021a4
setObjAttrModel
lantoli Apr 11, 2025
ee84181
test case: JSON maps not supported yet
lantoli Apr 11, 2025
8fde16d
more test cases in TestUnmarshalErrors
lantoli Apr 11, 2025
775d095
extract tom setAttrModel and setObjAttrTfModel
lantoli Apr 11, 2025
3aafe5a
getNullAttr for objects
lantoli Apr 11, 2025
46af9fe
Unmarshal recursive support for objects
lantoli Apr 11, 2025
39b9484
refactor setObjAttrModel
lantoli Apr 11, 2025
da33c25
support list and set of strings at root level
lantoli Apr 11, 2025
ef9e8c3
failing test for object list
lantoli Apr 11, 2025
5b87cd5
getTfAttr
lantoli Apr 11, 2025
5c6f453
recursive logic in getCollectionElements
lantoli Apr 11, 2025
1c5589d
fix use of newValue
lantoli Apr 11, 2025
78e8d05
set example
lantoli Apr 11, 2025
4d58b66
small doc change
lantoli Apr 11, 2025
c0631b0
Map comment
lantoli Apr 14, 2025
942e99b
split file for marshal and unmarshal
lantoli Apr 14, 2025
367dc60
don't convert unknown to null in this PR
lantoli Apr 14, 2025
3ca85cd
recursive lists and sets
lantoli Apr 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
220 changes: 207 additions & 13 deletions internal/common/autogeneration/marshal.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package autogeneration

import (
"context"
"encoding/json"
"fmt"
"reflect"
Expand Down Expand Up @@ -38,7 +39,10 @@ func Marshal(model any, isUpdate bool) ([]byte, error) {
}

// Unmarshal gets a JSON (e.g. from an Atlas response) and unmarshals it into a Terraform model.
// It supports the following Terraform model types: String, Bool, Int64, Float64.
// It supports the following Terraform model types: String, Bool, Int64, Float64, Object, List, Set.
// Map is not supported yet as it is not used in the Atlas API.
// Attributes that are in JSON but not in the model are ignored, no error is returned.
// Object attributes that are unknown are converted to null as all values must be known in the response state.
func Unmarshal(raw []byte, model any) error {
var objJSON map[string]any
if err := json.Unmarshal(raw, &objJSON); err != nil {
Expand Down Expand Up @@ -73,7 +77,7 @@ func marshalAttr(attrNameModel string, attrValModel reflect.Value, objJSON map[s
if !ok {
panic("marshal expects only Terraform types in the model")
}
val, err := getAttr(obj)
val, err := getModelAttr(obj)
if err != nil {
return err
}
Expand All @@ -83,13 +87,15 @@ func marshalAttr(attrNameModel string, attrValModel reflect.Value, objJSON map[s
return nil
}

func getAttr(val attr.Value) (any, error) {
func getModelAttr(val attr.Value) (any, error) {
if val.IsNull() || val.IsUnknown() {
return nil, nil // skip null or unknown values
}
switch v := val.(type) {
case types.String:
return v.ValueString(), nil
case types.Bool:
return v.ValueBool(), nil
case types.Int64:
return v.ValueInt64(), nil
case types.Float64:
Expand All @@ -110,7 +116,7 @@ func getAttr(val attr.Value) (any, error) {
func getListAttr(elms []attr.Value) (any, error) {
arr := make([]any, 0)
for _, attr := range elms {
valChild, err := getAttr(attr)
valChild, err := getModelAttr(attr)
if err != nil {
return nil, err
}
Expand All @@ -121,10 +127,12 @@ func getListAttr(elms []attr.Value) (any, error) {
return arr, nil
}

// getMapAttr gets a map of attributes and returns a map of JSON attributes.
// keepKeyCase is used for types.Map to keep key case. However, we want to use JSON key case for types.Object
func getMapAttr(elms map[string]attr.Value, keepKeyCase bool) (any, error) {
obj := make(map[string]any)
objJSON := make(map[string]any)
for name, attr := range elms {
valChild, err := getAttr(attr)
valChild, err := getModelAttr(attr)
if err != nil {
return nil, err
}
Expand All @@ -133,10 +141,10 @@ func getMapAttr(elms map[string]attr.Value, keepKeyCase bool) (any, error) {
if keepKeyCase {
nameJSON = name
}
obj[nameJSON] = valChild
objJSON[nameJSON] = valChild
}
}
return obj, nil
return objJSON, nil
}

func unmarshalAttrs(objJSON map[string]any, model any) error {
Expand All @@ -153,6 +161,7 @@ func unmarshalAttrs(objJSON map[string]any, model any) error {
return err
}
}
convertUnknownToNull(valModel)
return nil
}

Expand All @@ -164,26 +173,211 @@ func unmarshalAttr(attrNameJSON string, attrObjJSON any, valModel reflect.Value)
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it worth it to log a debug message?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure, in general I prefer not to log debug messages, I don't know if this case is so important to be logged

switch v := attrObjJSON.(type) {
case string:
return setAttrModel(attrNameModel, fieldModel, types.StringValue(v))
return setAttrTfModel(attrNameModel, fieldModel, types.StringValue(v))
case bool:
return setAttrModel(attrNameModel, fieldModel, types.BoolValue(v))
return setAttrTfModel(attrNameModel, fieldModel, types.BoolValue(v))
case float64: // number: try int or float
if setAttrModel(attrNameModel, fieldModel, types.Float64Value(v)) == nil {
if setAttrTfModel(attrNameModel, fieldModel, types.Float64Value(v)) == nil {
return nil
}
return setAttrModel(attrNameModel, fieldModel, types.Int64Value(int64(v)))
return setAttrTfModel(attrNameModel, fieldModel, types.Int64Value(int64(v)))
case nil:
return nil // skip nil values, no need to set anything
case map[string]any:
obj, ok := fieldModel.Interface().(types.Object)
if !ok {
return fmt.Errorf("unmarshal expects object for field %s", attrNameJSON)
}
objNew, err := setObjAttrModel(obj, v)
if err != nil {
return err
}
return setAttrTfModel(attrNameModel, fieldModel, objNew)
case []any:
switch collection := fieldModel.Interface().(type) {
case types.List:
list, err := setListAttrModel(collection, v)
if err != nil {
return err
}
return setAttrTfModel(attrNameModel, fieldModel, list)
case types.Set:
set, err := setSetAttrModel(collection, v)
if err != nil {
return err
}
return setAttrTfModel(attrNameModel, fieldModel, set)
}
return fmt.Errorf("unmarshal expects array for field %s", attrNameJSON)
default:
return fmt.Errorf("unmarshal not supported yet for type %T for field %s", v, attrNameJSON)
}
}

func setAttrModel(name string, field reflect.Value, val attr.Value) error {
func setAttrTfModel(name string, field reflect.Value, val attr.Value) error {
obj := reflect.ValueOf(val)
if !field.Type().AssignableTo(obj.Type()) {
return fmt.Errorf("unmarshal can't assign value to model field %s", name)
}
field.Set(obj)
return nil
}

func setMapAttrModel(name string, value any, mapAttrs map[string]attr.Value, mapTypes map[string]attr.Type) error {
nameChildTf := xstrings.ToSnakeCase(name)
valueType, found := mapTypes[nameChildTf]
if !found {
return nil // skip attributes that are not in the model
}
newValue, err := getTfAttr(value, valueType, mapAttrs[nameChildTf])
if err != nil {
return err
}
if newValue != nil {
mapAttrs[nameChildTf] = newValue
}
return nil
}

func getTfAttr(value any, valueType attr.Type, oldValue attr.Value) (attr.Value, error) {
switch v := value.(type) {
case string:
if valueType == types.StringType {
return types.StringValue(v), nil
}
return nil, fmt.Errorf("unmarshal gets incorrect string for value: %v", v)
case bool:
if valueType == types.BoolType {
return types.BoolValue(v), nil
}
return nil, fmt.Errorf("unmarshal gets incorrect bool for value: %v", v)
case float64:
switch valueType {
case types.Int64Type:
return types.Int64Value(int64(v)), nil
case types.Float64Type:
return types.Float64Value(v), nil
}
return nil, fmt.Errorf("unmarshal gets incorrect number for value: %v", v)
case map[string]any:
obj, ok := oldValue.(types.Object)
if !ok {
return nil, fmt.Errorf("unmarshal gets incorrect object for value: %v", v)
}
objNew, err := setObjAttrModel(obj, v)
if err != nil {
return nil, err
}
return objNew, nil
case nil:
return nil, nil // skip nil values, no need to set anything
}
return nil, fmt.Errorf("unmarshal not supported yet for type %T", value)
}

func setObjAttrModel(obj types.Object, objJSON map[string]any) (attr.Value, error) {
mapAttrs, mapTypes, err := getObjAttrsAndTypes(obj)
if err != nil {
return nil, err
}
for nameChild, valueChild := range objJSON {
if err := setMapAttrModel(nameChild, valueChild, mapAttrs, mapTypes); err != nil {
return nil, err
}
}
objNew, diags := types.ObjectValue(obj.AttributeTypes(context.Background()), mapAttrs)
if diags.HasError() {
return nil, fmt.Errorf("unmarshal failed to convert map to object: %v", diags)
}
return objNew, nil
}

func setListAttrModel(list types.List, arrayJSON []any) (attr.Value, error) {
elmType := list.ElementType(context.Background())
elms, err := getCollectionElements(arrayJSON, elmType)
if err != nil {
return nil, err
}
listNew, diags := types.ListValue(elmType, elms)
if diags.HasError() {
return nil, fmt.Errorf("unmarshal failed to convert list to object: %v", diags)
}
return listNew, nil
}

func setSetAttrModel(set types.Set, arrayJSON []any) (attr.Value, error) {
elmType := set.ElementType(context.Background())
elms, err := getCollectionElements(arrayJSON, elmType)
if err != nil {
return nil, err
}
setNew, diags := types.SetValue(elmType, elms)
if diags.HasError() {
return nil, fmt.Errorf("unmarshal failed to convert set to object: %v", diags)
}
return setNew, nil
}

func getCollectionElements(arrayJSON []any, valueType attr.Type) ([]attr.Value, error) {
elms := make([]attr.Value, len(arrayJSON))
nullVal, err := getNullAttr(valueType)
if err != nil {
return nil, err
}
for i, item := range arrayJSON {
newValue, err := getTfAttr(item, valueType, nullVal)
if err != nil {
return nil, err
}
if newValue != nil {
elms[i] = newValue
}
}
return elms, nil
}

func getObjAttrsAndTypes(obj types.Object) (mapAttrs map[string]attr.Value, mapTypes map[string]attr.Type, err error) {
// mapTypes has all attributes, mapAttrs might not have them, e.g. in null or unknown objects
mapAttrs = obj.Attributes()
mapTypes = obj.AttributeTypes(context.Background())
for attrName, attrType := range mapTypes {
if _, found := mapAttrs[attrName]; found {
continue // skip attributes that are already set
}
nullVal, err := getNullAttr(attrType)
if err != nil {
return nil, nil, err
}
mapAttrs[attrName] = nullVal
}
return mapAttrs, mapTypes, nil
}

func getNullAttr(attrType attr.Type) (attr.Value, error) {
switch attrType {
case types.StringType:
return types.StringNull(), nil
case types.BoolType:
return types.BoolNull(), nil
case types.Int64Type:
return types.Int64Null(), nil
case types.Float64Type:
return types.Float64Null(), nil
default:
if objType, ok := attrType.(types.ObjectType); ok {
return types.ObjectNull(objType.AttributeTypes()), nil
}
return nil, fmt.Errorf("unmarshal to get null value not supported yet for type %T", attrType)
}
}

func convertUnknownToNull(valModel reflect.Value) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this logic be recursive?

Copy link
Member Author

@lantoli lantoli Apr 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unknown to null logic moved to another PR

for i := 0; i < valModel.NumField(); i++ {
field := valModel.Field(i)
if obj, ok := field.Interface().(types.Object); ok {
if obj.IsUnknown() && field.CanSet() {
field.Set(reflect.ValueOf(types.ObjectNull(obj.AttributeTypes(context.Background()))))
}
}
}
}
Loading