Skip to content

Commit 9d767c4

Browse files
authored
chore: Define conversion function for generating API request body from Resource Model in create (non-nested) (#3239)
* create Marshal * marshal string * make marshal and umarshal func naming more consistent * TestMarshalUnsupported and TestMarshalPanic * panic messages * failing test for omitjson * typo * implement omitjson * createonly * adjust comment * simplify const * rename creatonly tag to omitjsonupdate * document Marshal * fix tfsdk tags
1 parent f8ee8d7 commit 9d767c4

File tree

2 files changed

+249
-55
lines changed

2 files changed

+249
-55
lines changed

internal/common/autogeneration/marshal.go

+97-28
Original file line numberDiff line numberDiff line change
@@ -10,61 +10,130 @@ import (
1010
"github.com/huandu/xstrings"
1111
)
1212

13+
const (
14+
tagKey = "autogeneration"
15+
tagValOmitJSON = "omitjson"
16+
tagValOmitJSONUpdate = "omitjsonupdate"
17+
)
18+
19+
// Marshal gets a Terraform model and marshals it into JSON (e.g. for an Atlas request).
20+
// It supports the following Terraform model types: String, Bool, Int64, Float64.
21+
// Attributes that are null or unknown are not marshaled.
22+
// Attributes with autogeneration tag `omitjson` are never marshaled.
23+
// Attributes with autogeneration tag `omitjsonupdate` are not marshaled if isUpdate is true.
24+
func Marshal(model any, isUpdate bool) ([]byte, error) {
25+
valModel := reflect.ValueOf(model)
26+
if valModel.Kind() != reflect.Ptr {
27+
panic("model must be pointer")
28+
}
29+
valModel = valModel.Elem()
30+
if valModel.Kind() != reflect.Struct {
31+
panic("model must be pointer to struct")
32+
}
33+
objJSON, err := marshalAttrs(valModel, isUpdate)
34+
if err != nil {
35+
return nil, err
36+
}
37+
return json.Marshal(objJSON)
38+
}
39+
1340
// Unmarshal gets a JSON (e.g. from an Atlas response) and unmarshals it into a Terraform model.
1441
// It supports the following Terraform model types: String, Bool, Int64, Float64.
15-
func Unmarshal(raw []byte, dest any) error {
16-
var src map[string]any
17-
if err := json.Unmarshal(raw, &src); err != nil {
42+
func Unmarshal(raw []byte, model any) error {
43+
var objJSON map[string]any
44+
if err := json.Unmarshal(raw, &objJSON); err != nil {
1845
return err
1946
}
20-
return mapFields(src, dest)
47+
return unmarshalAttrs(objJSON, model)
48+
}
49+
50+
func marshalAttrs(valModel reflect.Value, isUpdate bool) (map[string]any, error) {
51+
objJSON := make(map[string]any)
52+
for i := 0; i < valModel.NumField(); i++ {
53+
attrTypeModel := valModel.Type().Field(i)
54+
tag := attrTypeModel.Tag.Get(tagKey)
55+
if tag == tagValOmitJSON {
56+
continue // skip fields with tag `omitjson`
57+
}
58+
if isUpdate && tag == tagValOmitJSONUpdate {
59+
continue // skip fields with tag `omitjsonupdate` if in update mode
60+
}
61+
attrNameModel := attrTypeModel.Name
62+
attrValModel := valModel.Field(i)
63+
if err := marshalAttr(attrNameModel, attrValModel, objJSON); err != nil {
64+
return nil, err
65+
}
66+
}
67+
return objJSON, nil
68+
}
69+
70+
func marshalAttr(attrNameModel string, attrValModel reflect.Value, objJSON map[string]any) error {
71+
attrNameJSON := xstrings.ToSnakeCase(attrNameModel)
72+
obj, ok := attrValModel.Interface().(attr.Value)
73+
if !ok {
74+
panic("marshal expects only Terraform types in the model")
75+
}
76+
if obj.IsNull() || obj.IsUnknown() {
77+
return nil // skip null or unknown values
78+
}
79+
switch v := attrValModel.Interface().(type) {
80+
case types.String:
81+
objJSON[attrNameJSON] = v.ValueString()
82+
case types.Int64:
83+
objJSON[attrNameJSON] = v.ValueInt64()
84+
case types.Float64:
85+
objJSON[attrNameJSON] = v.ValueFloat64()
86+
default:
87+
return fmt.Errorf("marshal not supported yet for type %T for field %s", v, attrNameJSON)
88+
}
89+
return nil
2190
}
2291

23-
func mapFields(src map[string]any, dest any) error {
24-
valDest := reflect.ValueOf(dest)
25-
if valDest.Kind() != reflect.Ptr {
26-
panic("dest must be pointer")
92+
func unmarshalAttrs(objJSON map[string]any, model any) error {
93+
valModel := reflect.ValueOf(model)
94+
if valModel.Kind() != reflect.Ptr {
95+
panic("model must be pointer")
2796
}
28-
valDest = valDest.Elem()
29-
if valDest.Kind() != reflect.Struct {
30-
panic("dest must be pointer to struct")
97+
valModel = valModel.Elem()
98+
if valModel.Kind() != reflect.Struct {
99+
panic("model must be pointer to struct")
31100
}
32-
for nameAttrSrc, valueAttrSrc := range src {
33-
if err := mapField(nameAttrSrc, valueAttrSrc, valDest); err != nil {
101+
for attrNameJSON, attrObjJSON := range objJSON {
102+
if err := unmarshalAttr(attrNameJSON, attrObjJSON, valModel); err != nil {
34103
return err
35104
}
36105
}
37106
return nil
38107
}
39108

40-
func mapField(nameAttrSrc string, valueAttrSrc any, valDest reflect.Value) error {
41-
nameDest := xstrings.ToPascalCase(nameAttrSrc)
42-
fieldDest := valDest.FieldByName(nameDest)
43-
if !fieldDest.CanSet() {
109+
func unmarshalAttr(attrNameJSON string, attrObjJSON any, valModel reflect.Value) error {
110+
attrNameModel := xstrings.ToPascalCase(attrNameJSON)
111+
fieldModel := valModel.FieldByName(attrNameModel)
112+
if !fieldModel.CanSet() {
44113
return nil // skip fields that cannot be set, are invalid or not found
45114
}
46-
switch v := valueAttrSrc.(type) {
115+
switch v := attrObjJSON.(type) {
47116
case string:
48-
return assignField(nameDest, fieldDest, types.StringValue(v))
117+
return setAttrModel(attrNameModel, fieldModel, types.StringValue(v))
49118
case bool:
50-
return assignField(nameDest, fieldDest, types.BoolValue(v))
119+
return setAttrModel(attrNameModel, fieldModel, types.BoolValue(v))
51120
case float64: // number: try int or float
52-
if assignField(nameDest, fieldDest, types.Float64Value(v)) == nil {
121+
if setAttrModel(attrNameModel, fieldModel, types.Float64Value(v)) == nil {
53122
return nil
54123
}
55-
return assignField(nameDest, fieldDest, types.Int64Value(int64(v)))
124+
return setAttrModel(attrNameModel, fieldModel, types.Int64Value(int64(v)))
56125
case nil:
57126
return nil // skip nil values, no need to set anything
58127
default:
59-
return fmt.Errorf("not supported yet type %T for field %s", v, nameAttrSrc)
128+
return fmt.Errorf("unmarshal not supported yet for type %T for field %s", v, attrNameJSON)
60129
}
61130
}
62131

63-
func assignField(nameDest string, fieldDest reflect.Value, valueDest attr.Value) error {
64-
valObj := reflect.ValueOf(valueDest)
65-
if !fieldDest.Type().AssignableTo(valObj.Type()) {
66-
return fmt.Errorf("can't assign value to model field %s", nameDest)
132+
func setAttrModel(name string, field reflect.Value, val attr.Value) error {
133+
obj := reflect.ValueOf(val)
134+
if !field.Type().AssignableTo(obj.Type()) {
135+
return fmt.Errorf("unmarshal can't assign value to model field %s", name)
67136
}
68-
fieldDest.Set(valObj)
137+
field.Set(obj)
69138
return nil
70139
}

internal/common/autogeneration/marshal_test.go

+152-27
Original file line numberDiff line numberDiff line change
@@ -3,50 +3,175 @@ package autogeneration_test
33
import (
44
"testing"
55

6+
"github.com/hashicorp/terraform-plugin-framework/attr"
67
"github.com/hashicorp/terraform-plugin-framework/types"
78
"github.com/mongodb/terraform-provider-mongodbatlas/internal/common/autogeneration"
89
"github.com/stretchr/testify/assert"
910
"github.com/stretchr/testify/require"
1011
)
1112

13+
func TestMarshalBasic(t *testing.T) {
14+
model := struct {
15+
AttrFloat types.Float64 `tfsdk:"attr_float"`
16+
AttrString types.String `tfsdk:"attr_string"`
17+
// values with tag `omitjson` are not marshaled, and they don't need to be Terraform types
18+
AttrOmit types.String `tfsdk:"attr_omit" autogeneration:"omitjson"`
19+
AttrOmitNoTerraform string `autogeneration:"omitjson"`
20+
AttrUnkown types.String `tfsdk:"attr_unknown"`
21+
AttrNull types.String `tfsdk:"attr_null"`
22+
AttrInt types.Int64 `tfsdk:"attr_int"`
23+
}{
24+
AttrFloat: types.Float64Value(1.234),
25+
AttrString: types.StringValue("hello"),
26+
AttrOmit: types.StringValue("omit"),
27+
AttrOmitNoTerraform: "omit",
28+
AttrUnkown: types.StringUnknown(), // unknown values are not marshaled
29+
AttrNull: types.StringNull(), // null values are not marshaled
30+
AttrInt: types.Int64Value(1),
31+
}
32+
const expectedJSON = `{ "attr_string": "hello", "attr_int": 1, "attr_float": 1.234 }`
33+
raw, err := autogeneration.Marshal(&model, false)
34+
require.NoError(t, err)
35+
assert.JSONEq(t, expectedJSON, string(raw))
36+
}
37+
38+
func TestMarshalOmitJSONUpdate(t *testing.T) {
39+
const (
40+
expectedCreate = `{ "attr": "val1", "attr_omit_update": "val2" }`
41+
expectedUpdate = `{ "attr": "val1" }`
42+
)
43+
model := struct {
44+
Attr types.String `tfsdk:"attr"`
45+
AttrOmitUpdate types.String `tfsdk:"attr_omit_update" autogeneration:"omitjsonupdate"`
46+
AttrOmit types.String `tfsdk:"attr_omit" autogeneration:"omitjson"`
47+
}{
48+
Attr: types.StringValue("val1"),
49+
AttrOmitUpdate: types.StringValue("val2"),
50+
AttrOmit: types.StringValue("omit"),
51+
}
52+
create, errCreate := autogeneration.Marshal(&model, false)
53+
require.NoError(t, errCreate)
54+
assert.JSONEq(t, expectedCreate, string(create))
55+
56+
update, errUpdate := autogeneration.Marshal(&model, true)
57+
require.NoError(t, errUpdate)
58+
assert.JSONEq(t, expectedUpdate, string(update))
59+
}
60+
61+
func TestMarshalUnsupported(t *testing.T) {
62+
testCases := map[string]any{
63+
"Object not supported yet, only no-nested types": &struct {
64+
Attr types.Object
65+
}{
66+
Attr: types.ObjectValueMust(map[string]attr.Type{
67+
"key": types.StringType,
68+
}, map[string]attr.Value{
69+
"key": types.StringValue("value"),
70+
}),
71+
},
72+
"List not supported yet, only no-nested types": &struct {
73+
Attr types.List
74+
}{
75+
Attr: types.ListValueMust(types.StringType, []attr.Value{
76+
types.StringValue("value"),
77+
}),
78+
},
79+
"Map not supported yet, only no-nested types": &struct {
80+
Attr types.Map
81+
}{
82+
Attr: types.MapValueMust(types.StringType, map[string]attr.Value{
83+
"key": types.StringValue("value"),
84+
}),
85+
},
86+
"Set not supported yet, only no-nested types": &struct {
87+
Attr types.Set
88+
}{
89+
Attr: types.SetValueMust(types.StringType, []attr.Value{
90+
types.StringValue("value"),
91+
}),
92+
},
93+
"Int32 not supported yet as it's not being used in any model": &struct {
94+
Attr types.Int32
95+
}{
96+
Attr: types.Int32Value(1),
97+
},
98+
"Float32 not supported yet as it's not being used in any model": &struct {
99+
Attr types.Float32
100+
}{
101+
Attr: types.Float32Value(1.0),
102+
},
103+
}
104+
for name, model := range testCases {
105+
t.Run(name, func(t *testing.T) {
106+
raw, err := autogeneration.Marshal(model, false)
107+
require.Error(t, err)
108+
assert.Nil(t, raw)
109+
})
110+
}
111+
}
112+
113+
func TestMarshalPanic(t *testing.T) {
114+
str := "string"
115+
testCases := map[string]any{
116+
"no Terraform types": &struct {
117+
Attr string
118+
}{
119+
Attr: "a",
120+
},
121+
"no pointer": struct {
122+
Attr types.String
123+
}{
124+
Attr: types.StringValue("a"),
125+
},
126+
"no struct": &str,
127+
}
128+
for name, model := range testCases {
129+
t.Run(name, func(t *testing.T) {
130+
assert.Panics(t, func() {
131+
_, _ = autogeneration.Marshal(model, false)
132+
})
133+
})
134+
}
135+
}
136+
12137
func TestUnmarshalBasic(t *testing.T) {
13138
var model struct {
14-
AttributeFloat types.Float64 `tfsdk:"attribute_float"`
15-
AttributeFloatWithInt types.Float64 `tfsdk:"attribute_float_with_int"`
16-
AttributeString types.String `tfsdk:"attribute_string"`
17-
AttributeNotInJSON types.String `tfsdk:"attribute_not_in_json"`
18-
AttributeInt types.Int64 `tfsdk:"attribute_int"`
19-
AttributeIntWithFloat types.Int64 `tfsdk:"attribute_int_with_float"`
20-
AttributeTrue types.Bool `tfsdk:"attribute_true"`
21-
AttributeFalse types.Bool `tfsdk:"attribute_false"`
139+
AttrFloat types.Float64 `tfsdk:"attr_float"`
140+
AttrFloatWithInt types.Float64 `tfsdk:"attr_float_with_int"`
141+
AttrString types.String `tfsdk:"attr_string"`
142+
AttrNotInJSON types.String `tfsdk:"attr_not_in_json"`
143+
AttrInt types.Int64 `tfsdk:"attr_int"`
144+
AttrIntWithFloat types.Int64 `tfsdk:"attr_int_with_float"`
145+
AttrTrue types.Bool `tfsdk:"attr_true"`
146+
AttrFalse types.Bool `tfsdk:"attr_false"`
22147
}
23148
const (
24149
epsilon = 10e-15 // float tolerance
25150
// attribute_not_in_model is ignored because it is not in the model, no error is thrown.
26151
// attribute_null is ignored because it is null, no error is thrown even if it is not in the model.
27152
tfResponseJSON = `
28153
{
29-
"attribute_string": "value_string",
30-
"attribute_true": true,
31-
"attribute_false": false,
32-
"attribute_int": 123,
33-
"attribute_int_with_float": 10.6,
34-
"attribute_float": 456.1,
35-
"attribute_float_with_int": 13,
36-
"attribute_not_in_model": "val",
37-
"attribute_null": null
154+
"attr_string": "value_string",
155+
"attr_true": true,
156+
"attr_false": false,
157+
"attr_int": 123,
158+
"attr_int_with_float": 10.6,
159+
"attr_float": 456.1,
160+
"attr_float_with_int": 13,
161+
"attr_not_in_model": "val",
162+
"attr_null": null
38163
}
39164
`
40165
)
41166
require.NoError(t, autogeneration.Unmarshal([]byte(tfResponseJSON), &model))
42-
assert.Equal(t, "value_string", model.AttributeString.ValueString())
43-
assert.True(t, model.AttributeTrue.ValueBool())
44-
assert.False(t, model.AttributeFalse.ValueBool())
45-
assert.Equal(t, int64(123), model.AttributeInt.ValueInt64())
46-
assert.Equal(t, int64(10), model.AttributeIntWithFloat.ValueInt64()) // response floats stored in model ints have their decimals stripped.
47-
assert.InEpsilon(t, float64(456.1), model.AttributeFloat.ValueFloat64(), epsilon)
48-
assert.InEpsilon(t, float64(13), model.AttributeFloatWithInt.ValueFloat64(), epsilon)
49-
assert.True(t, model.AttributeNotInJSON.IsNull()) // attributes not in JSON response are not changed, so null is kept.
167+
assert.Equal(t, "value_string", model.AttrString.ValueString())
168+
assert.True(t, model.AttrTrue.ValueBool())
169+
assert.False(t, model.AttrFalse.ValueBool())
170+
assert.Equal(t, int64(123), model.AttrInt.ValueInt64())
171+
assert.Equal(t, int64(10), model.AttrIntWithFloat.ValueInt64()) // response floats stored in model ints have their decimals stripped.
172+
assert.InEpsilon(t, float64(456.1), model.AttrFloat.ValueFloat64(), epsilon)
173+
assert.InEpsilon(t, float64(13), model.AttrFloatWithInt.ValueFloat64(), epsilon)
174+
assert.True(t, model.AttrNotInJSON.IsNull()) // attributes not in JSON response are not changed, so null is kept.
50175
}
51176

52177
func TestUnmarshalErrors(t *testing.T) {
@@ -132,11 +257,11 @@ func TestUnmarshalUnsupportedResponse(t *testing.T) {
132257
}{
133258
"JSON objects not support yet": {
134259
responseJSON: `{"attr": {"key": "value"}}`,
135-
errorStr: "not supported yet type map[string]interface {} for field attr",
260+
errorStr: "unmarshal not supported yet for type map[string]interface {} for field attr",
136261
},
137262
"JSON arrays not supported yet": {
138263
responseJSON: `{"attr": [{"key": "value"}]}`,
139-
errorStr: "not supported yet type []interface {} for field attr",
264+
errorStr: "unmarshal not supported yet for type []interface {} for field attr",
140265
},
141266
}
142267
for name, tc := range testCases {

0 commit comments

Comments
 (0)