diff --git a/internal/common/autogeneration/marshal.go b/internal/common/autogeneration/marshal.go index 6b8c06954b..158434955e 100644 --- a/internal/common/autogeneration/marshal.go +++ b/internal/common/autogeneration/marshal.go @@ -37,16 +37,6 @@ func Marshal(model any, isUpdate bool) ([]byte, error) { return json.Marshal(objJSON) } -// 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. -func Unmarshal(raw []byte, model any) error { - var objJSON map[string]any - if err := json.Unmarshal(raw, &objJSON); err != nil { - return err - } - return unmarshalAttrs(objJSON, model) -} - func marshalAttrs(valModel reflect.Value, isUpdate bool) (map[string]any, error) { objJSON := make(map[string]any) for i := 0; i < valModel.NumField(); i++ { @@ -73,7 +63,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 } @@ -83,13 +73,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: @@ -110,7 +102,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 } @@ -121,10 +113,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 } @@ -133,57 +127,8 @@ func getMapAttr(elms map[string]attr.Value, keepKeyCase bool) (any, error) { if keepKeyCase { nameJSON = name } - obj[nameJSON] = valChild + objJSON[nameJSON] = valChild } } - return obj, nil -} - -func unmarshalAttrs(objJSON map[string]any, model any) error { - valModel := reflect.ValueOf(model) - if valModel.Kind() != reflect.Ptr { - panic("model must be pointer") - } - valModel = valModel.Elem() - if valModel.Kind() != reflect.Struct { - panic("model must be pointer to struct") - } - for attrNameJSON, attrObjJSON := range objJSON { - if err := unmarshalAttr(attrNameJSON, attrObjJSON, valModel); err != nil { - return err - } - } - return nil -} - -func unmarshalAttr(attrNameJSON string, attrObjJSON any, valModel reflect.Value) error { - attrNameModel := xstrings.ToPascalCase(attrNameJSON) - fieldModel := valModel.FieldByName(attrNameModel) - if !fieldModel.CanSet() { - return nil // skip fields that cannot be set, are invalid or not found - } - switch v := attrObjJSON.(type) { - case string: - return setAttrModel(attrNameModel, fieldModel, types.StringValue(v)) - case bool: - return setAttrModel(attrNameModel, fieldModel, types.BoolValue(v)) - case float64: // number: try int or float - if setAttrModel(attrNameModel, fieldModel, types.Float64Value(v)) == nil { - return nil - } - return setAttrModel(attrNameModel, fieldModel, types.Int64Value(int64(v))) - case nil: - return nil // skip nil values, no need to set anything - 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 { - 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 + return objJSON, nil } diff --git a/internal/common/autogeneration/marshal_test.go b/internal/common/autogeneration/marshal_test.go index b116015b1d..9e71496508 100644 --- a/internal/common/autogeneration/marshal_test.go +++ b/internal/common/autogeneration/marshal_test.go @@ -10,15 +10,35 @@ import ( "github.com/stretchr/testify/require" ) -type TFTestModel struct { - AttrString types.String `tfsdk:"attr_string"` - AttrInt types.Int64 `tfsdk:"attr_int"` +type modelTest struct { + AttrFloat types.Float64 `tfsdk:"attr_float"` + AttrString types.String `tfsdk:"attr_string"` + AttrInt types.Int64 `tfsdk:"attr_int"` + AttrBool types.Bool `tfsdk:"attr_bool"` } -var TestObjType = types.ObjectType{AttrTypes: map[string]attr.Type{ - "attr_string": types.StringType, - "attr_int": types.Int64Type, -}} +type modelParentTest struct { + AttrParentObj types.Object `tfsdk:"attr_parent_obj"` + AttrParentString types.String `tfsdk:"attr_parent_string"` + AttrParentInt types.Int64 `tfsdk:"attr_parent_int"` +} + +var ( + objTypeTest = types.ObjectType{AttrTypes: map[string]attr.Type{ + "attr_float": types.Float64Type, + "attr_string": types.StringType, + "attr_int": types.Int64Type, + "attr_bool": types.BoolType, + }} + + objTypeParentTest = types.ObjectType{AttrTypes: map[string]attr.Type{ + "attr_parent_obj": objTypeTest, + "attr_parent_string": types.StringType, + "attr_parent_int": types.Int64Type, + }} +) + +const epsilon = 10e-15 // float tolerance in test equality func TestMarshalBasic(t *testing.T) { model := struct { @@ -30,6 +50,9 @@ func TestMarshalBasic(t *testing.T) { AttrUnkown types.String `tfsdk:"attr_unknown"` AttrNull types.String `tfsdk:"attr_null"` AttrInt types.Int64 `tfsdk:"attr_int"` + AttrBoolTrue types.Bool `tfsdk:"attr_bool_true"` + AttrBoolFalse types.Bool `tfsdk:"attr_bool_false"` + AttrBoolNull types.Bool `tfsdk:"attr_bool_null"` }{ AttrFloat: types.Float64Value(1.234), AttrString: types.StringValue("hello"), @@ -38,15 +61,18 @@ func TestMarshalBasic(t *testing.T) { AttrUnkown: types.StringUnknown(), // unknown values are not marshaled AttrNull: types.StringNull(), // null values are not marshaled AttrInt: types.Int64Value(1), + AttrBoolTrue: types.BoolValue(true), + AttrBoolFalse: types.BoolValue(false), + AttrBoolNull: types.BoolNull(), // null values are not marshaled } - const expectedJSON = `{ "attrString": "hello", "attrInt": 1, "attrFloat": 1.234 }` + const expectedJSON = `{ "attrString": "hello", "attrInt": 1, "attrFloat": 1.234, "attrBoolTrue": true, "attrBoolFalse": false }` raw, err := autogeneration.Marshal(&model, false) require.NoError(t, err) assert.JSONEq(t, expectedJSON, string(raw)) } func TestMarshalNestedAllTypes(t *testing.T) { - attrListObj, diags := types.ListValueFrom(t.Context(), TestObjType, []TFTestModel{ + attrListObj, diags := types.ListValueFrom(t.Context(), objTypeTest, []modelTest{ { AttrString: types.StringValue("str1"), AttrInt: types.Int64Value(1), @@ -57,7 +83,7 @@ func TestMarshalNestedAllTypes(t *testing.T) { }, }) assert.False(t, diags.HasError()) - attrSetObj, diags := types.SetValueFrom(t.Context(), TestObjType, []TFTestModel{ + attrSetObj, diags := types.SetValueFrom(t.Context(), objTypeTest, []modelTest{ { AttrString: types.StringValue("str11"), AttrInt: types.Int64Value(11), @@ -68,7 +94,7 @@ func TestMarshalNestedAllTypes(t *testing.T) { }, }) assert.False(t, diags.HasError()) - attrMapObj, diags := types.MapValueFrom(t.Context(), TestObjType, map[string]TFTestModel{ + attrMapObj, diags := types.MapValueFrom(t.Context(), objTypeTest, map[string]modelTest{ "keyOne": { AttrString: types.StringValue("str1"), AttrInt: types.Int64Value(1), @@ -128,29 +154,23 @@ func TestMarshalNestedAllTypes(t *testing.T) { } func TestMarshalNestedMultiLevel(t *testing.T) { - type parentModel struct { - AttrParentObj types.Object `tfsdk:"attr_parent_obj"` - AttrParentString types.String `tfsdk:"attr_parent_string"` - AttrParentInt types.Int64 `tfsdk:"attr_parent_int"` - } - parentObjType := types.ObjectType{AttrTypes: map[string]attr.Type{ - "attr_parent_obj": TestObjType, - "attr_parent_string": types.StringType, - "attr_parent_int": types.Int64Type, - }} - attrListObj, diags := types.ListValueFrom(t.Context(), parentObjType, []parentModel{ + attrListObj, diags := types.ListValueFrom(t.Context(), objTypeParentTest, []modelParentTest{ { - AttrParentObj: types.ObjectValueMust(TestObjType.AttrTypes, map[string]attr.Value{ + AttrParentObj: types.ObjectValueMust(objTypeTest.AttrTypes, map[string]attr.Value{ "attr_string": types.StringValue("str11"), "attr_int": types.Int64Value(11), + "attr_float": types.Float64Value(11.1), + "attr_bool": types.BoolValue(true), }), AttrParentString: types.StringValue("str1"), AttrParentInt: types.Int64Value(1), }, { - AttrParentObj: types.ObjectValueMust(TestObjType.AttrTypes, map[string]attr.Value{ + AttrParentObj: types.ObjectValueMust(objTypeTest.AttrTypes, map[string]attr.Value{ "attr_string": types.StringValue("str22"), "attr_int": types.Int64Value(22), + "attr_float": types.Float64Value(22.2), + "attr_bool": types.BoolValue(false), }), AttrParentString: types.StringValue("str2"), AttrParentInt: types.Int64Value(2), @@ -174,7 +194,9 @@ func TestMarshalNestedMultiLevel(t *testing.T) { "attrParentInt": 1, "attrParentObj": { "attrString": "str11", - "attrInt": 11 + "attrInt": 11, + "attrFloat": 11.1, + "attrBool": true } }, { @@ -182,7 +204,9 @@ func TestMarshalNestedMultiLevel(t *testing.T) { "attrParentInt": 2, "attrParentObj": { "attrString": "str22", - "attrInt": 22 + "attrInt": 22, + "attrFloat": 22.2, + "attrBool": false } } ] @@ -261,140 +285,3 @@ func TestMarshalPanic(t *testing.T) { }) } } - -func TestUnmarshalBasic(t *testing.T) { - var model struct { - AttrFloat types.Float64 `tfsdk:"attr_float"` - AttrFloatWithInt types.Float64 `tfsdk:"attr_float_with_int"` - AttrString types.String `tfsdk:"attr_string"` - AttrNotInJSON types.String `tfsdk:"attr_not_in_json"` - AttrInt types.Int64 `tfsdk:"attr_int"` - AttrIntWithFloat types.Int64 `tfsdk:"attr_int_with_float"` - AttrTrue types.Bool `tfsdk:"attr_true"` - AttrFalse types.Bool `tfsdk:"attr_false"` - } - const ( - epsilon = 10e-15 // float tolerance - // attribute_not_in_model is ignored because it is not in the model, no error is thrown. - // attribute_null is ignored because it is null, no error is thrown even if it is not in the model. - tfResponseJSON = ` - { - "attrString": "value_string", - "attrTrue": true, - "attrFalse": false, - "attrInt": 123, - "attrIntWithFloat": 10.6, - "attrFloat": 456.1, - "attrFloatWithInt": 13, - "attrNotInModel": "val", - "attrNull": null - } - ` - ) - require.NoError(t, autogeneration.Unmarshal([]byte(tfResponseJSON), &model)) - assert.Equal(t, "value_string", model.AttrString.ValueString()) - assert.True(t, model.AttrTrue.ValueBool()) - assert.False(t, model.AttrFalse.ValueBool()) - assert.Equal(t, int64(123), model.AttrInt.ValueInt64()) - assert.Equal(t, int64(10), model.AttrIntWithFloat.ValueInt64()) // response floats stored in model ints have their decimals stripped. - assert.InEpsilon(t, float64(456.1), model.AttrFloat.ValueFloat64(), epsilon) - assert.InEpsilon(t, float64(13), model.AttrFloatWithInt.ValueFloat64(), epsilon) - assert.True(t, model.AttrNotInJSON.IsNull()) // attributes not in JSON response are not changed, so null is kept. -} - -func TestUnmarshalErrors(t *testing.T) { - const errorStr = "can't assign value to model field Attr" - testCases := map[string]struct { - model any - responseJSON string - }{ - "response ints are not converted to model strings": { - responseJSON: `{"attr": 123}`, // - model: &struct { - Attr types.String - }{}, - }, - "response strings are not converted to model ints": { - responseJSON: `{"attr": "hello"}`, - model: &struct { - Attr types.Int64 - }{}, - }, - "response strings are not converted to model bools": { - responseJSON: `{"attr": "true"}`, - model: &struct { - Attr types.Bool - }{}, - }, - "response bools are not converted to model string": { - responseJSON: `{"attr": true}`, - model: &struct { - Attr types.String - }{}, - }, - "model attributes have to be of Terraform types": { - responseJSON: `{"attr": "hello"}`, - model: &struct { - Attr string - }{}, - }, - } - for name, tc := range testCases { - t.Run(name, func(t *testing.T) { - assert.ErrorContains(t, autogeneration.Unmarshal([]byte(tc.responseJSON), tc.model), errorStr) - }) - } -} - -// TestUnmarshalUnsupportedModel has Terraform types not supported yet. -// It will be updated when we add support for them. -func TestUnmarshalUnsupportedModel(t *testing.T) { - testCases := map[string]struct { - model any - responseJSON string - }{ - "Int32 not supported yet as it's not being used in any model": { - responseJSON: `{"attr": 1}`, - model: &struct { - Attr types.Int32 - }{}, - }, - "Float32 not supported yet as it's not being used in any model": { - responseJSON: `{"attr": 1}`, - model: &struct { - Attr types.Float32 - }{}, - }, - } - for name, tc := range testCases { - t.Run(name, func(t *testing.T) { - assert.Error(t, autogeneration.Unmarshal([]byte(tc.responseJSON), tc.model)) - }) - } -} - -// TestUnmarshalUnsupportedResponse has JSON response types not supported yet. -// It will be updated when we add support for them. -func TestUnmarshalUnsupportedResponse(t *testing.T) { - var model struct { - Attr types.String - } - testCases := map[string]struct { - responseJSON string - errorStr string - }{ - "JSON objects not support yet": { - responseJSON: `{"attr": {"key": "value"}}`, - errorStr: "unmarshal not supported yet for type map[string]interface {} for field attr", - }, - "JSON arrays not supported yet": { - responseJSON: `{"attr": [{"key": "value"}]}`, - errorStr: "unmarshal not supported yet for type []interface {} for field attr", - }, - } - for name, tc := range testCases { - t.Run(name, func(t *testing.T) { - assert.ErrorContains(t, autogeneration.Unmarshal([]byte(tc.responseJSON), &model), tc.errorStr) - }) - } -} diff --git a/internal/common/autogeneration/unmarshal.go b/internal/common/autogeneration/unmarshal.go new file mode 100644 index 0000000000..d940b6f6a8 --- /dev/null +++ b/internal/common/autogeneration/unmarshal.go @@ -0,0 +1,269 @@ +package autogeneration + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/huandu/xstrings" +) + +// 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, Object, List, Set. +// Map is not supported yet, will be done in CLOUDP-312797. +// Attributes that are in JSON but not in the model are ignored, no error is returned. +func Unmarshal(raw []byte, model any) error { + var objJSON map[string]any + if err := json.Unmarshal(raw, &objJSON); err != nil { + return err + } + return unmarshalAttrs(objJSON, model) +} + +func unmarshalAttrs(objJSON map[string]any, model any) error { + valModel := reflect.ValueOf(model) + if valModel.Kind() != reflect.Ptr { + panic("model must be pointer") + } + valModel = valModel.Elem() + if valModel.Kind() != reflect.Struct { + panic("model must be pointer to struct") + } + for attrNameJSON, attrObjJSON := range objJSON { + if err := unmarshalAttr(attrNameJSON, attrObjJSON, valModel); err != nil { + return err + } + } + return nil +} + +func unmarshalAttr(attrNameJSON string, attrObjJSON any, valModel reflect.Value) error { + attrNameModel := xstrings.ToPascalCase(attrNameJSON) + fieldModel := valModel.FieldByName(attrNameModel) + if !fieldModel.CanSet() { + return nil // skip fields that cannot be set, are invalid or not found + } + switch v := attrObjJSON.(type) { + case string: + return setAttrTfModel(attrNameModel, fieldModel, types.StringValue(v)) + case bool: + return setAttrTfModel(attrNameModel, fieldModel, types.BoolValue(v)) + case float64: // number: try int or float + if setAttrTfModel(attrNameModel, fieldModel, types.Float64Value(v)) == nil { + return nil + } + 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 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 []any: + if list, ok := oldValue.(types.List); ok { + listNew, err := setListAttrModel(list, v) + if err != nil { + return nil, err + } + return listNew, nil + } + if set, ok := oldValue.(types.Set); ok { + setNew, err := setSetAttrModel(set, v) + if err != nil { + return nil, err + } + return setNew, nil + } + return nil, fmt.Errorf("unmarshal gets incorrect array for value: %v", v) + 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 + } + if listType, ok := attrType.(types.ListType); ok { + return types.ListNull(listType.ElemType), nil + } + if setType, ok := attrType.(types.SetType); ok { + return types.SetNull(setType.ElemType), nil + } + return nil, fmt.Errorf("unmarshal to get null value not supported yet for type %T", attrType) + } +} diff --git a/internal/common/autogeneration/unmarshal_test.go b/internal/common/autogeneration/unmarshal_test.go new file mode 100644 index 0000000000..7a5a7380a2 --- /dev/null +++ b/internal/common/autogeneration/unmarshal_test.go @@ -0,0 +1,439 @@ +package autogeneration_test + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/mongodb/terraform-provider-mongodbatlas/internal/common/autogeneration" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUnmarshalBasic(t *testing.T) { + var model struct { + AttrFloat types.Float64 `tfsdk:"attr_float"` + AttrFloatWithInt types.Float64 `tfsdk:"attr_float_with_int"` + AttrString types.String `tfsdk:"attr_string"` + AttrNotInJSON types.String `tfsdk:"attr_not_in_json"` + AttrInt types.Int64 `tfsdk:"attr_int"` + AttrIntWithFloat types.Int64 `tfsdk:"attr_int_with_float"` + AttrTrue types.Bool `tfsdk:"attr_true"` + AttrFalse types.Bool `tfsdk:"attr_false"` + } + const ( + // attribute_not_in_model is ignored because it is not in the model, no error is thrown. + // attribute_null is ignored because it is null, no error is thrown even if it is not in the model. + jsonResp = ` + { + "attrString": "value_string", + "attrTrue": true, + "attrFalse": false, + "attrInt": 123, + "attrIntWithFloat": 10.6, + "attrFloat": 456.1, + "attrFloatWithInt": 13, + "attrNotInModel": "val", + "attrNull": null + } + ` + ) + require.NoError(t, autogeneration.Unmarshal([]byte(jsonResp), &model)) + assert.Equal(t, "value_string", model.AttrString.ValueString()) + assert.True(t, model.AttrTrue.ValueBool()) + assert.False(t, model.AttrFalse.ValueBool()) + assert.Equal(t, int64(123), model.AttrInt.ValueInt64()) + assert.Equal(t, int64(10), model.AttrIntWithFloat.ValueInt64()) // response floats stored in model ints have their decimals stripped. + assert.InEpsilon(t, float64(456.1), model.AttrFloat.ValueFloat64(), epsilon) + assert.InEpsilon(t, float64(13), model.AttrFloatWithInt.ValueFloat64(), epsilon) + assert.True(t, model.AttrNotInJSON.IsNull()) // attributes not in JSON response are not changed, so null is kept. +} + +func TestUnmarshalNestedAllTypes(t *testing.T) { + type modelst struct { + AttrObj types.Object `tfsdk:"attr_obj"` + AttrObjNullNotSent types.Object `tfsdk:"attr_obj_null_not_sent"` + AttrObjNullSent types.Object `tfsdk:"attr_obj_null_sent"` + AttrObjUnknownNotSent types.Object `tfsdk:"attr_obj_unknown_not_sent"` + AttrObjUnknownSent types.Object `tfsdk:"attr_obj_unknown_sent"` + AttrObjParent types.Object `tfsdk:"attr_obj_parent"` + AttrListString types.List `tfsdk:"attr_list_string"` + AttrListObj types.List `tfsdk:"attr_list_obj"` + AttrSetString types.Set `tfsdk:"attr_set_string"` + AttrSetObj types.Set `tfsdk:"attr_set_obj"` + AttrListListString types.List `tfsdk:"attr_list_list_string"` + AttrSetListObj types.Set `tfsdk:"attr_set_list_obj"` + } + model := modelst{ + AttrObj: types.ObjectValueMust(objTypeTest.AttrTypes, map[string]attr.Value{ + // these attribute values are irrelevant, they will be overwritten with JSON values + "attr_string": types.StringValue("different_string"), + "attr_int": types.Int64Value(123456), + "attr_float": types.Float64Unknown(), // can even be null + "attr_bool": types.BoolUnknown(), // can even be unknown + }), + AttrObjNullNotSent: types.ObjectNull(objTypeTest.AttrTypes), + AttrObjNullSent: types.ObjectNull(objTypeTest.AttrTypes), + AttrObjUnknownNotSent: types.ObjectUnknown(objTypeTest.AttrTypes), // unknown values are changed to null + AttrObjUnknownSent: types.ObjectUnknown(objTypeTest.AttrTypes), + AttrObjParent: types.ObjectNull(objTypeParentTest.AttrTypes), + AttrListString: types.ListUnknown(types.StringType), + AttrListObj: types.ListUnknown(objTypeTest), + AttrSetString: types.SetUnknown(types.StringType), + AttrSetObj: types.SetUnknown(objTypeTest), + AttrListListString: types.ListUnknown(types.ListType{ElemType: types.StringType}), + AttrSetListObj: types.SetUnknown(types.ListType{ElemType: objTypeTest}), + } + // attrUnexisting is ignored because it is in JSON but not in the model, no error is returned + const ( + jsonResp = ` + { + "attrObj": { + "attrString": "value_string", + "attrInt": 123, + "attrFloat": 1.1, + "attrBool": true, + "attrUnexisting": "val" + }, + "attrObjNullSent": { + "attrString": "null_obj", + "attrInt": 1, + "attrFloat": null + }, + "attrObjUnknownSent": { + "attrString": "unknown_obj" + }, + "attrObjParent": { + "attrParentString": "parent string", + "attrParentObj": { + "attrString": "inside parent string" + } + }, + "attrListString": [ + "list1", + "list2" + ], + "attrListObj": [ + { + "attrString": "list1", + "attrInt": 1, + "attrFloat": 1.1, + "attrBool": true + }, + { + "attrString": "list2", + "attrInt": 2, + "attrFloat": 2.2, + "attrBool": false + } + ], + "attrSetString": [ + "set1", + "set2" + ], + "attrSetObj": [ + { + "attrString": "set1", + "attrInt": 11, + "attrFloat": 11.1, + "attrBool": false + }, + { + "attrString": "set2", + "attrInt": 22, + "attrFloat": 22.2, + "attrBool": true + } + ], + "attrListListString": [ + ["list1a", "list1b"], + ["list2a", "list2b", "list2c"] + ], + "attrSetListObj": [ + [{ + "attrString": "setList1", + "attrInt": 1, + "attrFloat": 1.1, + "attrBool": true + }, + { + "attrString": "setList2", + "attrInt": 2, + "attrFloat": 2.2, + "attrBool": false + }], + [{ + "attrString": "setList3", + "attrInt": 3, + "attrFloat": 3.3, + "attrBool": true + }, + { + "attrString": "setList4", + "attrInt": 4, + "attrFloat": 4.4, + "attrBool": false + }, + { + "attrString": "setList5", + "attrInt": 5, + "attrFloat": 5.5, + "attrBool": true + }] + ] + } + ` + ) + modelExpected := modelst{ + AttrObj: types.ObjectValueMust(objTypeTest.AttrTypes, map[string]attr.Value{ + "attr_string": types.StringValue("value_string"), + "attr_int": types.Int64Value(123), + "attr_float": types.Float64Value(1.1), + "attr_bool": types.BoolValue(true), + }), + AttrObjNullNotSent: types.ObjectNull(objTypeTest.AttrTypes), + AttrObjNullSent: types.ObjectValueMust(objTypeTest.AttrTypes, map[string]attr.Value{ + "attr_string": types.StringValue("null_obj"), + "attr_int": types.Int64Value(1), + "attr_float": types.Float64Null(), + "attr_bool": types.BoolNull(), + }), + AttrObjUnknownNotSent: types.ObjectUnknown(objTypeTest.AttrTypes), + AttrObjUnknownSent: types.ObjectValueMust(objTypeTest.AttrTypes, map[string]attr.Value{ + "attr_string": types.StringValue("unknown_obj"), + "attr_int": types.Int64Null(), + "attr_float": types.Float64Null(), + "attr_bool": types.BoolNull(), + }), + AttrObjParent: types.ObjectValueMust(objTypeParentTest.AttrTypes, map[string]attr.Value{ + "attr_parent_string": types.StringValue("parent string"), + "attr_parent_int": types.Int64Null(), + "attr_parent_obj": types.ObjectValueMust(objTypeTest.AttrTypes, map[string]attr.Value{ + "attr_string": types.StringValue("inside parent string"), + "attr_int": types.Int64Null(), + "attr_float": types.Float64Null(), + "attr_bool": types.BoolNull(), + }), + }), + AttrListString: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("list1"), + types.StringValue("list2"), + }), + AttrListObj: types.ListValueMust(objTypeTest, []attr.Value{ + types.ObjectValueMust(objTypeTest.AttrTypes, map[string]attr.Value{ + "attr_string": types.StringValue("list1"), + "attr_int": types.Int64Value(1), + "attr_float": types.Float64Value(1.1), + "attr_bool": types.BoolValue(true), + }), + types.ObjectValueMust(objTypeTest.AttrTypes, map[string]attr.Value{ + "attr_string": types.StringValue("list2"), + "attr_int": types.Int64Value(2), + "attr_float": types.Float64Value(2.2), + "attr_bool": types.BoolValue(false), + }), + }), + AttrSetString: types.SetValueMust(types.StringType, []attr.Value{ + types.StringValue("set1"), + types.StringValue("set2"), + }), + AttrSetObj: types.SetValueMust(objTypeTest, []attr.Value{ + types.ObjectValueMust(objTypeTest.AttrTypes, map[string]attr.Value{ + "attr_string": types.StringValue("set1"), + "attr_int": types.Int64Value(11), + "attr_float": types.Float64Value(11.1), + "attr_bool": types.BoolValue(false), + }), + types.ObjectValueMust(objTypeTest.AttrTypes, map[string]attr.Value{ + "attr_string": types.StringValue("set2"), + "attr_int": types.Int64Value(22), + "attr_float": types.Float64Value(22.2), + "attr_bool": types.BoolValue(true), + }), + }), + AttrListListString: types.ListValueMust(types.ListType{ElemType: types.StringType}, []attr.Value{ + types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("list1a"), + types.StringValue("list1b"), + }), + types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("list2a"), + types.StringValue("list2b"), + types.StringValue("list2c"), + }), + }), + AttrSetListObj: types.SetValueMust(types.ListType{ElemType: objTypeTest}, []attr.Value{ + types.ListValueMust(objTypeTest, []attr.Value{ + types.ObjectValueMust(objTypeTest.AttrTypes, map[string]attr.Value{ + "attr_string": types.StringValue("setList1"), + "attr_int": types.Int64Value(1), + "attr_float": types.Float64Value(1.1), + "attr_bool": types.BoolValue(true), + }), + types.ObjectValueMust(objTypeTest.AttrTypes, map[string]attr.Value{ + "attr_string": types.StringValue("setList2"), + "attr_int": types.Int64Value(2), + "attr_float": types.Float64Value(2.2), + "attr_bool": types.BoolValue(false), + }), + }), + types.ListValueMust(objTypeTest, []attr.Value{ + types.ObjectValueMust(objTypeTest.AttrTypes, map[string]attr.Value{ + "attr_string": types.StringValue("setList3"), + "attr_int": types.Int64Value(3), + "attr_float": types.Float64Value(3.3), + "attr_bool": types.BoolValue(true), + }), + types.ObjectValueMust(objTypeTest.AttrTypes, map[string]attr.Value{ + "attr_string": types.StringValue("setList4"), + "attr_int": types.Int64Value(4), + "attr_float": types.Float64Value(4.4), + "attr_bool": types.BoolValue(false), + }), + types.ObjectValueMust(objTypeTest.AttrTypes, map[string]attr.Value{ + "attr_string": types.StringValue("setList5"), + "attr_int": types.Int64Value(5), + "attr_float": types.Float64Value(5.5), + "attr_bool": types.BoolValue(true), + }), + }), + }), + } + require.NoError(t, autogeneration.Unmarshal([]byte(jsonResp), &model)) + assert.Equal(t, modelExpected, model) +} + +func TestUnmarshalErrors(t *testing.T) { + testCases := map[string]struct { + model any + responseJSON string + errorStr string + }{ + "response ints are not converted to model strings": { + errorStr: "unmarshal can't assign value to model field Attr", + responseJSON: `{"attr": 123}`, + model: &struct { + Attr types.String + }{}, + }, + "response strings are not converted to model ints": { + errorStr: "unmarshal can't assign value to model field Attr", + responseJSON: `{"attr": "hello"}`, + model: &struct { + Attr types.Int64 + }{}, + }, + "response strings are not converted to model bools": { + errorStr: "unmarshal can't assign value to model field Attr", + responseJSON: `{"attr": "true"}`, + model: &struct { + Attr types.Bool + }{}, + }, + "response bools are not converted to model string": { + errorStr: "unmarshal can't assign value to model field Attr", + responseJSON: `{"attr": true}`, + model: &struct { + Attr types.String + }{}, + }, + "model attributes have to be of Terraform types": { + errorStr: "unmarshal can't assign value to model field Attr", + responseJSON: `{"attr": "hello"}`, + model: &struct { + Attr string + }{}, + }, + "model attr types in objects must match JSON types - string": { + errorStr: "unmarshal gets incorrect number for value: 1", + responseJSON: `{ "attrObj": { "attrString": 1 } }`, + model: &struct { + AttrObj types.Object `tfsdk:"attr_obj"` + }{ + AttrObj: types.ObjectNull(objTypeTest.AttrTypes), + }, + }, + "model attr types in objects must match JSON types - bool": { + errorStr: "unmarshal gets incorrect string for value: not a bool", + responseJSON: `{ "attrObj": { "attrBool": "not a bool" } }`, + model: &struct { + AttrObj types.Object `tfsdk:"attr_obj"` + }{ + AttrObj: types.ObjectNull(objTypeTest.AttrTypes), + }, + }, + "model attr types in objects must match JSON types - int": { + errorStr: "unmarshal gets incorrect string for value: not an int", + responseJSON: `{ "attrObj": { "attrInt": "not an int" } }`, + model: &struct { + AttrObj types.Object `tfsdk:"attr_obj"` + }{ + AttrObj: types.ObjectNull(objTypeTest.AttrTypes), + }, + }, + "model attr types in objects must match JSON types - float": { + errorStr: "unmarshal gets incorrect string for value: not an int", + responseJSON: `{ "attrObj": { "attrFloat": "not an int" } }`, + model: &struct { + AttrObj types.Object `tfsdk:"attr_obj"` + }{ + AttrObj: types.ObjectNull(objTypeTest.AttrTypes), + }, + }, + } + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + assert.ErrorContains(t, autogeneration.Unmarshal([]byte(tc.responseJSON), tc.model), tc.errorStr) + }) + } +} + +// TestUnmarshalUnsupportedModel has Terraform types not supported yet. +// It will be updated when we add support for them. +func TestUnmarshalUnsupportedModel(t *testing.T) { + testCases := map[string]struct { + model any + responseJSON string + }{ + "Int32 not supported yet as it's not being used in any model": { + responseJSON: `{"attr": 1}`, + model: &struct { + Attr types.Int32 + }{}, + }, + "Float32 not supported yet as it's not being used in any model": { + responseJSON: `{"attr": 1}`, + model: &struct { + Attr types.Float32 + }{}, + }, + } + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + assert.Error(t, autogeneration.Unmarshal([]byte(tc.responseJSON), tc.model)) + }) + } +} + +// TestUnmarshalUnsupportedResponse has JSON response types not supported yet. +// It will be updated when we add support for them. +func TestUnmarshalUnsupportedResponse(t *testing.T) { + testCases := map[string]struct { + model any + responseJSON string + errorStr string + }{ + "JSON maps not supported yet": { + model: &struct { + AttrMap types.Map `tfsdk:"attr_map"` + }{}, + responseJSON: `{"attrMap": {"key": "value"}}`, + errorStr: "unmarshal expects object for field attrMap", + }, + } + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + assert.ErrorContains(t, autogeneration.Unmarshal([]byte(tc.responseJSON), tc.model), tc.errorStr) + }) + } +}