From 2d6365da36e734d3d06613c6ce94c9d7bcd88b92 Mon Sep 17 00:00:00 2001 From: magodo Date: Sat, 23 Mar 2024 16:08:10 +0800 Subject: [PATCH 01/24] Add dynamic package --- internal/dynamic/dynamic.go | 231 +++++++++++++++++++++++++++++++ internal/dynamic/dynamic_test.go | 220 +++++++++++++++++++++++++++++ 2 files changed, 451 insertions(+) create mode 100644 internal/dynamic/dynamic.go create mode 100644 internal/dynamic/dynamic_test.go diff --git a/internal/dynamic/dynamic.go b/internal/dynamic/dynamic.go new file mode 100644 index 0000000..da79fb1 --- /dev/null +++ b/internal/dynamic/dynamic.go @@ -0,0 +1,231 @@ +package dynamic + +import ( + "encoding/json" + "fmt" + "math/big" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +func ToJSON(d types.Dynamic) ([]byte, error) { + return attrValueToJSON(d.UnderlyingValue()) +} + +func attrListToJSON(in []attr.Value) ([]json.RawMessage, error) { + var l []json.RawMessage + for _, v := range in { + vv, err := attrValueToJSON(v) + if err != nil { + return nil, err + } + l = append(l, json.RawMessage(vv)) + } + return l, nil +} + +func attrMapToJSON(in map[string]attr.Value) (map[string]json.RawMessage, error) { + m := map[string]json.RawMessage{} + for k, v := range in { + vv, err := attrValueToJSON(v) + if err != nil { + return nil, err + } + m[k] = json.RawMessage(vv) + } + return m, nil +} + +func attrValueToJSON(val attr.Value) ([]byte, error) { + switch value := val.(type) { + case types.Bool: + return json.Marshal(value.ValueBool()) + case types.String: + return json.Marshal(value.ValueString()) + case types.Int64: + return json.Marshal(value.ValueInt64()) + case types.Float64: + return json.Marshal(value.ValueFloat64()) + case types.Number: + v, _ := value.ValueBigFloat().Float64() + return json.Marshal(v) + case types.List: + l, err := attrListToJSON(value.Elements()) + if err != nil { + return nil, err + } + return json.Marshal(l) + case types.Set: + l, err := attrListToJSON(value.Elements()) + if err != nil { + return nil, err + } + return json.Marshal(l) + case types.Tuple: + l, err := attrListToJSON(value.Elements()) + if err != nil { + return nil, err + } + return json.Marshal(l) + case types.Map: + m, err := attrMapToJSON(value.Elements()) + if err != nil { + return nil, err + } + return json.Marshal(m) + case types.Object: + m, err := attrMapToJSON(value.Attributes()) + if err != nil { + return nil, err + } + return json.Marshal(m) + default: + return nil, fmt.Errorf("Unhandled type: %T", value) + } +} + +func FromJSON(b []byte, typ attr.Type) (types.Dynamic, error) { + v, err := attrValueFromJSON(b, typ) + if err != nil { + return types.Dynamic{}, err + } + return types.DynamicValue(v), nil +} + +func attrListFromJSON(b []byte, etyp attr.Type) ([]attr.Value, error) { + var l []json.RawMessage + if err := json.Unmarshal(b, &l); err != nil { + return nil, err + } + var vals []attr.Value + for _, b := range l { + val, err := attrValueFromJSON(b, etyp) + if err != nil { + return nil, err + } + vals = append(vals, val) + } + return vals, nil +} + +func attrValueFromJSON(b []byte, typ attr.Type) (attr.Value, error) { + switch typ := typ.(type) { + case basetypes.BoolType: + var v bool + if err := json.Unmarshal(b, &v); err != nil { + return nil, err + } + return types.BoolValue(v), nil + case basetypes.StringType: + var v string + if err := json.Unmarshal(b, &v); err != nil { + return nil, err + } + return types.StringValue(v), nil + case basetypes.Int64Type: + var v int64 + if err := json.Unmarshal(b, &v); err != nil { + return nil, err + } + return types.Int64Value(v), nil + case basetypes.Float64Type: + var v float64 + if err := json.Unmarshal(b, &v); err != nil { + return nil, err + } + return types.Float64Value(v), nil + case basetypes.NumberType: + var v float64 + if err := json.Unmarshal(b, &v); err != nil { + return nil, err + } + return types.NumberValue(big.NewFloat(v)), nil + case basetypes.ListType: + vals, err := attrListFromJSON(b, typ.ElemType) + if err != nil { + return nil, err + } + vv, diags := types.ListValue(typ.ElemType, vals) + if diags.HasError() { + diag := diags.Errors()[0] + return nil, fmt.Errorf("%s: %s", diag.Summary(), diag.Detail()) + } + return vv, nil + case basetypes.SetType: + vals, err := attrListFromJSON(b, typ.ElemType) + if err != nil { + return nil, err + } + vv, diags := types.SetValue(typ.ElemType, vals) + if diags.HasError() { + diag := diags.Errors()[0] + return nil, fmt.Errorf("%s: %s", diag.Summary(), diag.Detail()) + } + return vv, nil + case basetypes.TupleType: + var l []json.RawMessage + if err := json.Unmarshal(b, &l); err != nil { + return nil, err + } + if len(l) != len(typ.ElemTypes) { + return nil, fmt.Errorf("tuple element size not match") + } + var vals []attr.Value + for i, b := range l { + val, err := attrValueFromJSON(b, typ.ElemTypes[i]) + if err != nil { + return nil, err + } + vals = append(vals, val) + } + vv, diags := types.TupleValue(typ.ElemTypes, vals) + if diags.HasError() { + diag := diags.Errors()[0] + return nil, fmt.Errorf("%s: %s", diag.Summary(), diag.Detail()) + } + return vv, nil + case basetypes.MapType: + var m map[string]json.RawMessage + if err := json.Unmarshal(b, &m); err != nil { + return nil, err + } + vals := map[string]attr.Value{} + for k, v := range m { + val, err := attrValueFromJSON(v, typ.ElemType) + if err != nil { + return nil, err + } + vals[k] = val + } + vv, diags := types.MapValue(typ.ElemType, vals) + if diags.HasError() { + diag := diags.Errors()[0] + return nil, fmt.Errorf("%s: %s", diag.Summary(), diag.Detail()) + } + return vv, nil + case basetypes.ObjectType: + var m map[string]json.RawMessage + if err := json.Unmarshal(b, &m); err != nil { + return nil, err + } + vals := map[string]attr.Value{} + attrTypes := typ.AttributeTypes() + for k, v := range m { + val, err := attrValueFromJSON(v, attrTypes[k]) + if err != nil { + return nil, err + } + vals[k] = val + } + vv, diags := types.ObjectValue(attrTypes, vals) + if diags.HasError() { + diag := diags.Errors()[0] + return nil, fmt.Errorf("%s: %s", diag.Summary(), diag.Detail()) + } + return vv, nil + default: + return nil, fmt.Errorf("Unhandled type: %T", typ) + } +} diff --git a/internal/dynamic/dynamic_test.go b/internal/dynamic/dynamic_test.go new file mode 100644 index 0000000..779d217 --- /dev/null +++ b/internal/dynamic/dynamic_test.go @@ -0,0 +1,220 @@ +package dynamic + +import ( + "context" + "math/big" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stretchr/testify/require" +) + +func TestToJSON(t *testing.T) { + input := types.DynamicValue( + types.ObjectValueMust( + map[string]attr.Type{ + "bool": types.BoolType, + "string": types.StringType, + "int64": types.Int64Type, + "float64": types.Float64Type, + "number": types.NumberType, + "list": types.ListType{ + ElemType: types.BoolType, + }, + "set": types.SetType{ + ElemType: types.BoolType, + }, + "tuple": types.TupleType{ + ElemTypes: []attr.Type{ + types.BoolType, + types.StringType, + }, + }, + "map": types.MapType{ + ElemType: types.BoolType, + }, + "object": types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "bool": types.BoolType, + "string": types.StringType, + }, + }, + }, + map[string]attr.Value{ + "bool": types.BoolValue(true), + "string": types.StringValue("a"), + "int64": types.Int64Value(123), + "float64": types.Float64Value(1.23), + "number": types.NumberValue(big.NewFloat(1.23)), + "list": types.ListValueMust( + types.BoolType, + []attr.Value{ + types.BoolValue(true), + types.BoolValue(false), + }, + ), + "set": types.SetValueMust( + types.BoolType, + []attr.Value{ + types.BoolValue(true), + types.BoolValue(false), + }, + ), + "tuple": types.TupleValueMust( + []attr.Type{ + types.BoolType, + types.StringType, + }, + []attr.Value{ + types.BoolValue(true), + types.StringValue("a"), + }, + ), + "map": types.MapValueMust( + types.BoolType, + map[string]attr.Value{ + "a": types.BoolValue(true), + }, + ), + "object": types.ObjectValueMust( + map[string]attr.Type{ + "bool": types.BoolType, + "string": types.StringType, + }, + map[string]attr.Value{ + "bool": types.BoolValue(true), + "string": types.StringValue("a"), + }, + ), + }, + ), + ) + + expect := ` +{ + "bool": true, + "string": "a", + "int64": 123, + "float64": 1.23, + "number": 1.23, + "list": [true, false], + "set": [true, false], + "tuple": [true, "a"], + "map": { + "a": true + }, + "object": { + "bool": true, + "string": "a" + } +}` + + b, err := ToJSON(input) + require.NoError(t, err) + require.JSONEq(t, expect, string(b)) +} + +func TestFromJSON(t *testing.T) { + input := ` +{ + "bool": true, + "string": "a", + "int64": 123, + "float64": 1.23, + "number": 1.23, + "list": [true, false], + "set": [true, false], + "tuple": [true, "a"], + "map": { + "a": true + }, + "object": { + "bool": true, + "string": "a" + } +}` + expect := types.DynamicValue( + types.ObjectValueMust( + map[string]attr.Type{ + "bool": types.BoolType, + "string": types.StringType, + "int64": types.Int64Type, + "float64": types.Float64Type, + "number": types.NumberType, + "list": types.ListType{ + ElemType: types.BoolType, + }, + "set": types.SetType{ + ElemType: types.BoolType, + }, + "tuple": types.TupleType{ + ElemTypes: []attr.Type{ + types.BoolType, + types.StringType, + }, + }, + "map": types.MapType{ + ElemType: types.BoolType, + }, + "object": types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "bool": types.BoolType, + "string": types.StringType, + }, + }, + }, + map[string]attr.Value{ + "bool": types.BoolValue(true), + "string": types.StringValue("a"), + "int64": types.Int64Value(123), + "float64": types.Float64Value(1.23), + "number": types.NumberValue(big.NewFloat(1.23)), + "list": types.ListValueMust( + types.BoolType, + []attr.Value{ + types.BoolValue(true), + types.BoolValue(false), + }, + ), + "set": types.SetValueMust( + types.BoolType, + []attr.Value{ + types.BoolValue(true), + types.BoolValue(false), + }, + ), + "tuple": types.TupleValueMust( + []attr.Type{ + types.BoolType, + types.StringType, + }, + []attr.Value{ + types.BoolValue(true), + types.StringValue("a"), + }, + ), + "map": types.MapValueMust( + types.BoolType, + map[string]attr.Value{ + "a": types.BoolValue(true), + }, + ), + "object": types.ObjectValueMust( + map[string]attr.Type{ + "bool": types.BoolType, + "string": types.StringType, + }, + map[string]attr.Value{ + "bool": types.BoolValue(true), + "string": types.StringValue("a"), + }, + ), + }, + ), + ) + + actual, err := FromJSON([]byte(input), expect.UnderlyingValue().Type(context.TODO())) + require.NoError(t, err) + require.Equal(t, expect, actual) +} From 0a1888b488256cf8abf3a100b12c7fe4cef4fb23 Mon Sep 17 00:00:00 2001 From: magodo Date: Sun, 24 Mar 2024 10:55:15 +0800 Subject: [PATCH 02/24] Improve dynamics --- internal/dynamic/dynamic.go | 121 +++++++- internal/dynamic/dynamic_test.go | 491 +++++++++++++++++++++++++------ 2 files changed, 527 insertions(+), 85 deletions(-) diff --git a/internal/dynamic/dynamic.go b/internal/dynamic/dynamic.go index da79fb1..7b0401b 100644 --- a/internal/dynamic/dynamic.go +++ b/internal/dynamic/dynamic.go @@ -39,6 +39,9 @@ func attrMapToJSON(in map[string]attr.Value) (map[string]json.RawMessage, error) } func attrValueToJSON(val attr.Value) ([]byte, error) { + if val.IsNull() { + return json.Marshal(nil) + } switch value := val.(type) { case types.Bool: return json.Marshal(value.ValueBool()) @@ -113,36 +116,54 @@ func attrListFromJSON(b []byte, etyp attr.Type) ([]attr.Value, error) { func attrValueFromJSON(b []byte, typ attr.Type) (attr.Value, error) { switch typ := typ.(type) { case basetypes.BoolType: + if b == nil || string(b) == "null" { + return types.BoolNull(), nil + } var v bool if err := json.Unmarshal(b, &v); err != nil { return nil, err } return types.BoolValue(v), nil case basetypes.StringType: + if b == nil || string(b) == "null" { + return types.StringNull(), nil + } var v string if err := json.Unmarshal(b, &v); err != nil { return nil, err } return types.StringValue(v), nil case basetypes.Int64Type: + if b == nil || string(b) == "null" { + return types.Int64Null(), nil + } var v int64 if err := json.Unmarshal(b, &v); err != nil { return nil, err } return types.Int64Value(v), nil case basetypes.Float64Type: + if b == nil || string(b) == "null" { + return types.Float64Null(), nil + } var v float64 if err := json.Unmarshal(b, &v); err != nil { return nil, err } return types.Float64Value(v), nil case basetypes.NumberType: + if b == nil || string(b) == "null" { + return types.NumberNull(), nil + } var v float64 if err := json.Unmarshal(b, &v); err != nil { return nil, err } return types.NumberValue(big.NewFloat(v)), nil case basetypes.ListType: + if b == nil || string(b) == "null" { + return types.ListNull(typ.ElemType), nil + } vals, err := attrListFromJSON(b, typ.ElemType) if err != nil { return nil, err @@ -154,6 +175,9 @@ func attrValueFromJSON(b []byte, typ attr.Type) (attr.Value, error) { } return vv, nil case basetypes.SetType: + if b == nil || string(b) == "null" { + return types.SetNull(typ.ElemType), nil + } vals, err := attrListFromJSON(b, typ.ElemType) if err != nil { return nil, err @@ -165,12 +189,15 @@ func attrValueFromJSON(b []byte, typ attr.Type) (attr.Value, error) { } return vv, nil case basetypes.TupleType: + if b == nil || string(b) == "null" { + return types.TupleNull(typ.ElemTypes), nil + } var l []json.RawMessage if err := json.Unmarshal(b, &l); err != nil { return nil, err } if len(l) != len(typ.ElemTypes) { - return nil, fmt.Errorf("tuple element size not match") + return nil, fmt.Errorf("tuple element size not match: json=%d, type=%d", len(l), len(typ.ElemTypes)) } var vals []attr.Value for i, b := range l { @@ -187,6 +214,9 @@ func attrValueFromJSON(b []byte, typ attr.Type) (attr.Value, error) { } return vv, nil case basetypes.MapType: + if b == nil || string(b) == "null" { + return types.MapNull(typ.ElemType), nil + } var m map[string]json.RawMessage if err := json.Unmarshal(b, &m); err != nil { return nil, err @@ -206,14 +236,18 @@ func attrValueFromJSON(b []byte, typ attr.Type) (attr.Value, error) { } return vv, nil case basetypes.ObjectType: + if b == nil || string(b) == "null" { + return types.ObjectNull(typ.AttributeTypes()), nil + } var m map[string]json.RawMessage if err := json.Unmarshal(b, &m); err != nil { return nil, err } vals := map[string]attr.Value{} attrTypes := typ.AttributeTypes() - for k, v := range m { - val, err := attrValueFromJSON(v, attrTypes[k]) + + for k, attrType := range attrTypes { + val, err := attrValueFromJSON(m[k], attrType) if err != nil { return nil, err } @@ -229,3 +263,84 @@ func attrValueFromJSON(b []byte, typ attr.Type) (attr.Value, error) { return nil, fmt.Errorf("Unhandled type: %T", typ) } } + +// FromJSONImplied is similar to FromJSON, while it is for typeless case. +// In which case, the following type conversion rules are applied (Go -> TF): +// - bool: bool +// - float64: number +// - string: string +// - []interface{}: tuple +// - map[string]interface{}: object +// - nil: null (dynamic) +func FromJSONImplied(b []byte) (types.Dynamic, error) { + _, v, err := attrValueFromJSONImplied(b) + if err != nil { + return types.Dynamic{}, err + } + return types.DynamicValue(v), nil +} + +func attrValueFromJSONImplied(b []byte) (attr.Type, attr.Value, error) { + if string(b) == "null" { + return types.DynamicType, types.DynamicNull(), nil + } + + var object map[string]json.RawMessage + if err := json.Unmarshal(b, &object); err == nil { + attrTypes := map[string]attr.Type{} + attrVals := map[string]attr.Value{} + for k, v := range object { + attrTypes[k], attrVals[k], err = attrValueFromJSONImplied(v) + if err != nil { + return nil, nil, err + } + } + typ := types.ObjectType{AttrTypes: attrTypes} + val, diags := types.ObjectValue(attrTypes, attrVals) + if diags.HasError() { + diag := diags.Errors()[0] + return nil, nil, fmt.Errorf("%s: %s", diag.Summary(), diag.Detail()) + } + return typ, val, nil + } + + var array []json.RawMessage + if err := json.Unmarshal(b, &array); err == nil { + eTypes := []attr.Type{} + eVals := []attr.Value{} + for _, e := range array { + eType, eVal, err := attrValueFromJSONImplied(e) + if err != nil { + return nil, nil, err + } + eTypes = append(eTypes, eType) + eVals = append(eVals, eVal) + } + typ := types.TupleType{ElemTypes: eTypes} + val, diags := types.TupleValue(eTypes, eVals) + if diags.HasError() { + diag := diags.Errors()[0] + return nil, nil, fmt.Errorf("%s: %s", diag.Summary(), diag.Detail()) + } + return typ, val, nil + } + + // Primitives + var v interface{} + if err := json.Unmarshal(b, &v); err != nil { + return nil, nil, fmt.Errorf("failed to unmarshal %s: %v", string(b), err) + } + + switch v := v.(type) { + case bool: + return types.BoolType, types.BoolValue(v), nil + case float64: + return types.NumberType, types.NumberValue(big.NewFloat(v)), nil + case string: + return types.StringType, types.StringValue(v), nil + case nil: + return types.DynamicType, types.DynamicNull(), nil + default: + return nil, nil, fmt.Errorf("Unhandled type: %T", v) + } +} diff --git a/internal/dynamic/dynamic_test.go b/internal/dynamic/dynamic_test.go index 779d217..fea93eb 100644 --- a/internal/dynamic/dynamic_test.go +++ b/internal/dynamic/dynamic_test.go @@ -14,39 +14,70 @@ func TestToJSON(t *testing.T) { input := types.DynamicValue( types.ObjectValueMust( map[string]attr.Type{ - "bool": types.BoolType, - "string": types.StringType, - "int64": types.Int64Type, - "float64": types.Float64Type, - "number": types.NumberType, + "bool": types.BoolType, + "bool_null": types.BoolType, + "string": types.StringType, + "string_null": types.StringType, + "int64": types.Int64Type, + "int64_null": types.Int64Type, + "float64": types.Float64Type, + "float64_null": types.Float64Type, + "number": types.NumberType, + "number_null": types.NumberType, "list": types.ListType{ ElemType: types.BoolType, }, + "list_null": types.ListType{ + ElemType: types.BoolType, + }, "set": types.SetType{ ElemType: types.BoolType, }, + "set_null": types.SetType{ + ElemType: types.BoolType, + }, "tuple": types.TupleType{ ElemTypes: []attr.Type{ types.BoolType, types.StringType, }, }, + "tuple_null": types.TupleType{ + ElemTypes: []attr.Type{ + types.BoolType, + types.StringType, + }, + }, "map": types.MapType{ ElemType: types.BoolType, }, + "map_null": types.MapType{ + ElemType: types.BoolType, + }, "object": types.ObjectType{ AttrTypes: map[string]attr.Type{ "bool": types.BoolType, "string": types.StringType, }, }, + "object_null": types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "bool": types.BoolType, + "string": types.StringType, + }, + }, }, map[string]attr.Value{ - "bool": types.BoolValue(true), - "string": types.StringValue("a"), - "int64": types.Int64Value(123), - "float64": types.Float64Value(1.23), - "number": types.NumberValue(big.NewFloat(1.23)), + "bool": types.BoolValue(true), + "bool_null": types.BoolNull(), + "string": types.StringValue("a"), + "string_null": types.StringNull(), + "int64": types.Int64Value(123), + "int64_null": types.Int64Null(), + "float64": types.Float64Value(1.23), + "float64_null": types.Float64Null(), + "number": types.NumberValue(big.NewFloat(1.23)), + "number_null": types.NumberNull(), "list": types.ListValueMust( types.BoolType, []attr.Value{ @@ -54,6 +85,7 @@ func TestToJSON(t *testing.T) { types.BoolValue(false), }, ), + "list_null": types.ListNull(types.BoolType), "set": types.SetValueMust( types.BoolType, []attr.Value{ @@ -61,6 +93,7 @@ func TestToJSON(t *testing.T) { types.BoolValue(false), }, ), + "set_null": types.SetNull(types.BoolType), "tuple": types.TupleValueMust( []attr.Type{ types.BoolType, @@ -71,12 +104,19 @@ func TestToJSON(t *testing.T) { types.StringValue("a"), }, ), + "tuple_null": types.TupleNull( + []attr.Type{ + types.BoolType, + types.StringType, + }, + ), "map": types.MapValueMust( types.BoolType, map[string]attr.Value{ "a": types.BoolValue(true), }, ), + "map_null": types.MapNull(types.BoolType), "object": types.ObjectValueMust( map[string]attr.Type{ "bool": types.BoolType, @@ -87,6 +127,12 @@ func TestToJSON(t *testing.T) { "string": types.StringValue("a"), }, ), + "object_null": types.ObjectNull( + map[string]attr.Type{ + "bool": types.BoolType, + "string": types.StringType, + }, + ), }, ), ) @@ -94,20 +140,30 @@ func TestToJSON(t *testing.T) { expect := ` { "bool": true, + "bool_null": null, "string": "a", + "string_null": null, "int64": 123, + "int64_null": null, "float64": 1.23, + "float64_null": null, "number": 1.23, + "number_null": null, "list": [true, false], + "list_null": null, "set": [true, false], + "set_null": null, "tuple": [true, "a"], + "tuple_null": null, "map": { "a": true }, + "map_null": null, "object": { "bool": true, "string": "a" - } + }, + "object_null": null }` b, err := ToJSON(input) @@ -116,105 +172,376 @@ func TestToJSON(t *testing.T) { } func TestFromJSON(t *testing.T) { - input := ` + cases := []struct { + name string + input string + expect types.Dynamic + }{ + { + name: "basic", + input: ` { "bool": true, + "bool_null": null, "string": "a", + "string_null": null, "int64": 123, + "int64_null": null, "float64": 1.23, + "float64_null": null, "number": 1.23, + "number_null": null, "list": [true, false], + "list_null": null, "set": [true, false], + "set_null": null, "tuple": [true, "a"], + "tuple_null": null, "map": { "a": true }, + "map_null": null, "object": { "bool": true, "string": "a" - } -}` - expect := types.DynamicValue( - types.ObjectValueMust( - map[string]attr.Type{ - "bool": types.BoolType, - "string": types.StringType, - "int64": types.Int64Type, - "float64": types.Float64Type, - "number": types.NumberType, - "list": types.ListType{ - ElemType: types.BoolType, - }, - "set": types.SetType{ - ElemType: types.BoolType, - }, - "tuple": types.TupleType{ - ElemTypes: []attr.Type{ - types.BoolType, - types.StringType, - }, - }, - "map": types.MapType{ - ElemType: types.BoolType, - }, - "object": types.ObjectType{ - AttrTypes: map[string]attr.Type{ - "bool": types.BoolType, - "string": types.StringType, - }, - }, - }, - map[string]attr.Value{ - "bool": types.BoolValue(true), - "string": types.StringValue("a"), - "int64": types.Int64Value(123), - "float64": types.Float64Value(1.23), - "number": types.NumberValue(big.NewFloat(1.23)), - "list": types.ListValueMust( - types.BoolType, - []attr.Value{ - types.BoolValue(true), - types.BoolValue(false), + }, + "object_null": null +}`, + expect: types.DynamicValue( + types.ObjectValueMust( + map[string]attr.Type{ + "bool": types.BoolType, + "bool_null": types.BoolType, + "string": types.StringType, + "string_null": types.StringType, + "int64": types.Int64Type, + "int64_null": types.Int64Type, + "float64": types.Float64Type, + "float64_null": types.Float64Type, + "number": types.NumberType, + "number_null": types.NumberType, + "list": types.ListType{ + ElemType: types.BoolType, + }, + "list_null": types.ListType{ + ElemType: types.BoolType, + }, + "set": types.SetType{ + ElemType: types.BoolType, + }, + "set_null": types.SetType{ + ElemType: types.BoolType, + }, + "tuple": types.TupleType{ + ElemTypes: []attr.Type{ + types.BoolType, + types.StringType, + }, + }, + "tuple_null": types.TupleType{ + ElemTypes: []attr.Type{ + types.BoolType, + types.StringType, + }, + }, + "map": types.MapType{ + ElemType: types.BoolType, + }, + "map_null": types.MapType{ + ElemType: types.BoolType, + }, + "object": types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "bool": types.BoolType, + "string": types.StringType, + }, + }, + "object_null": types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "bool": types.BoolType, + "string": types.StringType, + }, + }, }, - ), - "set": types.SetValueMust( - types.BoolType, - []attr.Value{ - types.BoolValue(true), - types.BoolValue(false), + map[string]attr.Value{ + "bool": types.BoolValue(true), + "bool_null": types.BoolNull(), + "string": types.StringValue("a"), + "string_null": types.StringNull(), + "int64": types.Int64Value(123), + "int64_null": types.Int64Null(), + "float64": types.Float64Value(1.23), + "float64_null": types.Float64Null(), + "number": types.NumberValue(big.NewFloat(1.23)), + "number_null": types.NumberNull(), + "list": types.ListValueMust( + types.BoolType, + []attr.Value{ + types.BoolValue(true), + types.BoolValue(false), + }, + ), + "list_null": types.ListNull(types.BoolType), + "set": types.SetValueMust( + types.BoolType, + []attr.Value{ + types.BoolValue(true), + types.BoolValue(false), + }, + ), + "set_null": types.SetNull(types.BoolType), + "tuple": types.TupleValueMust( + []attr.Type{ + types.BoolType, + types.StringType, + }, + []attr.Value{ + types.BoolValue(true), + types.StringValue("a"), + }, + ), + "tuple_null": types.TupleNull( + []attr.Type{ + types.BoolType, + types.StringType, + }, + ), + "map": types.MapValueMust( + types.BoolType, + map[string]attr.Value{ + "a": types.BoolValue(true), + }, + ), + "map_null": types.MapNull(types.BoolType), + "object": types.ObjectValueMust( + map[string]attr.Type{ + "bool": types.BoolType, + "string": types.StringType, + }, + map[string]attr.Value{ + "bool": types.BoolValue(true), + "string": types.StringValue("a"), + }, + ), + "object_null": types.ObjectNull( + map[string]attr.Type{ + "bool": types.BoolType, + "string": types.StringType, + }, + ), }, ), - "tuple": types.TupleValueMust( - []attr.Type{ - types.BoolType, - types.StringType, + ), + }, + { + name: "fields not defined in type is ignored", + input: ` +{ + "str1": "a", + "str2": "b" +} +`, + expect: types.DynamicValue( + types.ObjectValueMust( + map[string]attr.Type{ + "str1": types.StringType, }, - []attr.Value{ - types.BoolValue(true), - types.StringValue("a"), + map[string]attr.Value{ + "str1": types.StringValue("a"), }, ), - "map": types.MapValueMust( - types.BoolType, + ), + }, + { + name: "fields defined in type not in JSON, set it as null", + input: ` +{ + "str1": "a" +} +`, + expect: types.DynamicValue( + types.ObjectValueMust( + map[string]attr.Type{ + "str1": types.StringType, + "str2": types.StringType, + }, map[string]attr.Value{ - "a": types.BoolValue(true), + "str1": types.StringValue("a"), + "str2": types.StringNull(), }, ), - "object": types.ObjectValueMust( + ), + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + actual, err := FromJSON([]byte(tt.input), tt.expect.UnderlyingValue().Type(context.TODO())) + require.NoError(t, err) + require.Equal(t, tt.expect, actual) + }) + } +} + +func TestFromJSONImplied(t *testing.T) { + cases := []struct { + name string + input string + expect types.Dynamic + }{ + { + name: "basic", + input: ` +{ + "bool": true, + "bool_null": null, + "string": "a", + "string_null": null, + "int64": 123, + "int64_null": null, + "float64": 1.23, + "float64_null": null, + "number": 1.23, + "number_null": null, + "list": [true, false], + "list_null": null, + "set": [true, false], + "set_null": null, + "tuple": [true, "a"], + "tuple_null": null, + "map": { + "a": true + }, + "map_null": null, + "object": { + "bool": true, + "string": "a" + }, + "object_null": null +}`, + expect: types.DynamicValue( + types.ObjectValueMust( map[string]attr.Type{ - "bool": types.BoolType, - "string": types.StringType, + "bool": types.BoolType, + "bool_null": types.DynamicType, + "string": types.StringType, + "string_null": types.DynamicType, + "int64": types.NumberType, + "int64_null": types.DynamicType, + "float64": types.NumberType, + "float64_null": types.DynamicType, + "number": types.NumberType, + "number_null": types.DynamicType, + "list": types.TupleType{ + ElemTypes: []attr.Type{ + types.BoolType, + types.BoolType, + }, + }, + "list_null": types.DynamicType, + "set": types.TupleType{ + ElemTypes: []attr.Type{ + types.BoolType, + types.BoolType, + }, + }, + "set_null": types.DynamicType, + "tuple": types.TupleType{ + ElemTypes: []attr.Type{ + types.BoolType, + types.StringType, + }, + }, + "tuple_null": types.DynamicType, + "map": types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "a": types.BoolType, + }, + }, + "map_null": types.DynamicType, + "object": types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "bool": types.BoolType, + "string": types.StringType, + }, + }, + "object_null": types.DynamicType, }, map[string]attr.Value{ - "bool": types.BoolValue(true), - "string": types.StringValue("a"), + "bool": types.BoolValue(true), + "bool_null": types.DynamicNull(), + "string": types.StringValue("a"), + "string_null": types.DynamicNull(), + "int64": types.NumberValue(big.NewFloat(123)), + "int64_null": types.DynamicNull(), + "float64": types.NumberValue(big.NewFloat(1.23)), + "float64_null": types.DynamicNull(), + "number": types.NumberValue(big.NewFloat(1.23)), + "number_null": types.DynamicNull(), + "list": types.TupleValueMust( + []attr.Type{ + types.BoolType, + types.BoolType, + }, + []attr.Value{ + types.BoolValue(true), + types.BoolValue(false), + }, + ), + "list_null": types.DynamicNull(), + "set": types.TupleValueMust( + []attr.Type{ + types.BoolType, + types.BoolType, + }, + []attr.Value{ + types.BoolValue(true), + types.BoolValue(false), + }, + ), + "set_null": types.DynamicNull(), + "tuple": types.TupleValueMust( + []attr.Type{ + types.BoolType, + types.StringType, + }, + []attr.Value{ + types.BoolValue(true), + types.StringValue("a"), + }, + ), + "tuple_null": types.DynamicNull(), + "map": types.ObjectValueMust( + map[string]attr.Type{ + "a": types.BoolType, + }, + map[string]attr.Value{ + "a": types.BoolValue(true), + }, + ), + "map_null": types.DynamicNull(), + "object": types.ObjectValueMust( + map[string]attr.Type{ + "bool": types.BoolType, + "string": types.StringType, + }, + map[string]attr.Value{ + "bool": types.BoolValue(true), + "string": types.StringValue("a"), + }, + ), + "object_null": types.DynamicNull(), }, ), - }, - ), - ) + ), + }, + } - actual, err := FromJSON([]byte(input), expect.UnderlyingValue().Type(context.TODO())) - require.NoError(t, err) - require.Equal(t, expect, actual) + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + actual, err := FromJSONImplied([]byte(tt.input)) + require.NoError(t, err) + require.Equal(t, tt.expect, actual) + }) + } } From 59b0aa4a755c64da5ca5fc5becbad74138416fd0 Mon Sep 17 00:00:00 2001 From: magodo Date: Sun, 24 Mar 2024 17:35:53 +0800 Subject: [PATCH 03/24] Refactoring `body` for `restful_resource` This commit changes the type of `body` from string to dynamic type. Accordingly, it removes the `write_only_attrs` as it now can be replaced by the `lifecycle.ignore_changes`. Whilst some of the attributes are still required: - `force_new_attrs`: This can't be replaced by `lifecycle.replace_triggered_by`, as the latter can't be applied to the resource itself as the point at which terraform must decide to replace a resource is before the provider has planned the change. See [this](https://github.com/hashicorp/terraform/issues/31702#issuecomment-1230380659) for details. - `output`: This *can* be changed from string to dynamic (as for `body`) for most cases. Whilst it might cause fatal error when the API returns different model given different input (e.g. an update of the API model introduces a new attribute in the read afterwards). The reason can be found at [here](https://developer.hashicorp.com/terraform/plugin/framework/handling-data/dynamic-data#understanding-type-consistency). - `output_attrs`: As the `output` is stil a string, this attribute is still needed. There are also some remaining things to do: - The import spec now breaks as the `body` needs encode type. There needs a grammer for the `body` to define its fw type. - The test needs to update - The data source and operationr esource needs to update There are also inconvinience than the previous way of using string, e.g. `[1,2,3]` is by default of type tuple, which will cause inconsistency error when the tuple's length changed. --- internal/provider/resource.go | 158 ++++++++++++++-------------------- 1 file changed, 65 insertions(+), 93 deletions(-) diff --git a/internal/provider/resource.go b/internal/provider/resource.go index c7eb9b0..56fcb85 100644 --- a/internal/provider/resource.go +++ b/internal/provider/resource.go @@ -6,7 +6,6 @@ import ( "fmt" "net/http" "net/url" - "strings" jsonpatch "github.com/evanphx/json-patch" "github.com/hashicorp/terraform-plugin-framework-validators/objectvalidator" @@ -24,13 +23,11 @@ import ( "github.com/magodo/terraform-provider-restful/internal/buildpath" "github.com/magodo/terraform-provider-restful/internal/client" "github.com/magodo/terraform-provider-restful/internal/defaults" + "github.com/magodo/terraform-provider-restful/internal/dynamic" myvalidator "github.com/magodo/terraform-provider-restful/internal/validator" "github.com/tidwall/gjson" ) -// Magic header used to indicate the value in the state is derived from import. -const __IMPORT_HEADER__ = "__RESTFUL_PROVIDER__" - type Resource struct { p *Provider } @@ -57,8 +54,7 @@ type resourceData struct { PrecheckUpdate types.List `tfsdk:"precheck_update"` PrecheckDelete types.List `tfsdk:"precheck_delete"` - Body types.String `tfsdk:"body"` - WriteOnlyAttributes types.List `tfsdk:"write_only_attrs"` + Body types.Dynamic `tfsdk:"body"` PollCreate types.Object `tfsdk:"poll_create"` PollUpdate types.Object `tfsdk:"poll_update"` @@ -388,13 +384,10 @@ func (r *Resource) Schema(ctx context.Context, req resource.SchemaRequest, resp }, }, - "body": schema.StringAttribute{ + "body": schema.DynamicAttribute{ Description: "The properties of the resource.", MarkdownDescription: "The properties of the resource.", Required: true, - Validators: []validator.String{ - myvalidator.StringIsJSON(), - }, }, "poll_create": pollAttribute("Create"), @@ -410,12 +403,6 @@ func (r *Resource) Schema(ctx context.Context, req resource.SchemaRequest, resp "retry_update": retryAttribute("Update (i.e. PUT/PATCH/POST)"), "retry_delete": retryAttribute("Delete (i.e. DELETE)"), - "write_only_attrs": schema.ListAttribute{ - Description: "A list of paths (in gjson syntax) to the attributes that are only settable, but won't be read in GET response.", - MarkdownDescription: "A list of paths (in [gjson syntax](https://github.com/tidwall/gjson/blob/master/SYNTAX.md)) to the attributes that are only settable, but won't be read in GET response.", - Optional: true, - ElementType: types.StringType, - }, "create_method": schema.StringAttribute{ Description: "The method used to create the resource. Possible values are `PUT` and `POST`. This overrides the `create_method` set in the provider block (defaults to POST).", MarkdownDescription: "The method used to create the resource. Possible values are `PUT` and `POST`. This overrides the `create_method` set in the provider block (defaults to POST).", @@ -484,28 +471,12 @@ func (r *Resource) Schema(ctx context.Context, req resource.SchemaRequest, resp } func (r *Resource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) { - var config resourceData - diags := req.Config.Get(ctx, &config) - resp.Diagnostics.Append(diags...) - if diags.HasError() { - return - } - - if !config.Body.IsUnknown() { - if !config.WriteOnlyAttributes.IsUnknown() && !config.WriteOnlyAttributes.IsNull() { - for _, ie := range config.WriteOnlyAttributes.Elements() { - ie := ie.(types.String) - if !ie.IsUnknown() && !ie.IsNull() { - if !gjson.Get(config.Body.ValueString(), ie.ValueString()).Exists() { - resp.Diagnostics.AddError( - "Invalid configuration", - fmt.Sprintf(`Invalid path in "write_only_attrs": %s`, ie.String()), - ) - } - } - } - } - } + // var config resourceData + // diags := req.Config.Get(ctx, &config) + // resp.Diagnostics.Append(diags...) + // if diags.HasError() { + // return + // } } func (r *Resource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { @@ -513,7 +484,6 @@ func (r *Resource) ModifyPlan(ctx context.Context, req resource.ModifyPlanReques // If the entire plan is null, the resource is planned for destruction. return } - if req.State.Raw.IsNull() { // If the entire state is null, the resource is planned for creation. return @@ -523,7 +493,6 @@ func (r *Resource) ModifyPlan(ctx context.Context, req resource.ModifyPlanReques resp.Diagnostics.Append(diags...) return } - if !plan.ForceNewAttrs.IsUnknown() && !plan.Body.IsUnknown() { var forceNewAttrs []types.String if diags := plan.ForceNewAttrs.ElementsAs(ctx, &forceNewAttrs, false); diags != nil { @@ -545,16 +514,23 @@ func (r *Resource) ModifyPlan(ctx context.Context, req resource.ModifyPlanReques return } - originJson := state.Body.ValueString() - if originJson == "" { - originJson = "{}" + originJson, err := dynamic.ToJSON(state.Body) + if err != nil { + resp.Diagnostics.AddError( + "ModifyPlan failed", + fmt.Sprintf("marshaling state body: %v", err), + ) } - modifiedJson := plan.Body.ValueString() - if modifiedJson == "" { - modifiedJson = "{}" + modifiedJson, err := dynamic.ToJSON(plan.Body) + if err != nil { + resp.Diagnostics.AddError( + "ModifyPlan failed", + fmt.Sprintf("marshaling plan body: %v", err), + ) } - patch, err := jsonpatch.CreateMergePatch([]byte(originJson), []byte(modifiedJson)) + + patch, err := jsonpatch.CreateMergePatch(originJson, modifiedJson) if err != nil { resp.Diagnostics.AddError("failed to create merge patch", err.Error()) return @@ -637,7 +613,15 @@ func (r Resource) Create(ctx context.Context, req resource.CreateRequest, resp * defer unlockFunc() // Create the resource - response, err := c.Create(ctx, plan.Path.ValueString(), plan.Body.ValueString(), *opt) + b, err := dynamic.ToJSON(plan.Body) + if err != nil { + resp.Diagnostics.AddError( + "Error to marshal body", + err.Error(), + ) + return + } + response, err := c.Create(ctx, plan.Path.ValueString(), string(b), *opt) if err != nil { resp.Diagnostics.AddError( "Error to call create", @@ -653,7 +637,7 @@ func (r Resource) Create(ctx context.Context, req resource.CreateRequest, resp * return } - b := response.Body() + b = response.Body() if sel := plan.CreateSelector.ValueString(); sel != "" { // Guaranteed by schema @@ -779,30 +763,18 @@ func (r Resource) Read(ctx context.Context, req resource.ReadRequest, resp *reso b = []byte(bodyLocator.LocateValueInResp(*response)) } - var writeOnlyAttributes []string - diags = state.WriteOnlyAttributes.ElementsAs(ctx, &writeOnlyAttributes, false) - resp.Diagnostics.Append(diags...) - if diags.HasError() { - return - } - - var body string - if strings.HasPrefix(state.Body.ValueString(), __IMPORT_HEADER__) { - // This branch is only invoked during `terraform import`. - body, err = ModifyBodyForImport(strings.TrimPrefix(state.Body.ValueString(), __IMPORT_HEADER__), string(b)) - } else { - body, err = ModifyBody(state.Body.ValueString(), string(b), writeOnlyAttributes) - } + var body types.Dynamic + body, err = dynamic.FromJSON(b, state.Body.UnderlyingValue().Type(ctx)) if err != nil { resp.Diagnostics.AddError( - "Modifying `body` during Read", + "Evaluating `body` during Read", err.Error(), ) return } // Set body, which is modified during read. - state.Body = types.StringValue(string(body)) + state.Body = body // Set output output := string(b) @@ -856,7 +828,7 @@ func (r Resource) Update(ctx context.Context, req resource.UpdateRequest, resp * } // Invoke API to Update the resource only when there are changes in the body. - if state.Body.ValueString() != plan.Body.ValueString() { + if !state.Body.Equal(plan.Body) { // Precheck unlockFunc, diags := precheck(ctx, c, r.p.apiOpt, state.ID.ValueString(), opt.Header, opt.Query, plan.PrecheckUpdate) if diags.HasError() { @@ -865,9 +837,24 @@ func (r Resource) Update(ctx context.Context, req resource.UpdateRequest, resp * } defer unlockFunc() - body := plan.Body.ValueString() + body, err := dynamic.ToJSON(plan.Body) + if err != nil { + resp.Diagnostics.AddError( + "Update failure", + fmt.Sprintf("Error to marshal plan body: %v", err), + ) + return + } if opt.Method == "PATCH" && !opt.MergePatchDisabled { - b, err := jsonpatch.CreateMergePatch([]byte(state.Body.ValueString()), []byte(plan.Body.ValueString())) + stateBodyJSON, err := dynamic.ToJSON(state.Body) + if err != nil { + resp.Diagnostics.AddError( + "Update failure", + fmt.Sprintf("Error to marshal state body: %v", err), + ) + return + } + b, err := jsonpatch.CreateMergePatch(body, stateBodyJSON) if err != nil { resp.Diagnostics.AddError( "Update failure", @@ -875,12 +862,11 @@ func (r Resource) Update(ctx context.Context, req resource.UpdateRequest, resp * ) return } - body = string(b) + body = b } path := plan.ID.ValueString() if !plan.UpdatePath.IsNull() { - var err error path, err = buildpath.BuildPath(plan.UpdatePath.ValueString(), r.p.apiOpt.BaseURL.String(), plan.Path.ValueString(), []byte(state.Output.ValueString())) if err != nil { resp.Diagnostics.AddError( @@ -1055,25 +1041,15 @@ type importSpec struct { // Path is the path used to create the resource. Path string `json:"path"` - // UpdatePath is the path used to update the resource - UpdatePath *string `json:"update_path"` - - // DeletePath is the path used to delte the resource - DeletePath *string `json:"delete_path"` - // Query is only required when it is mandatory for reading the resource. Query url.Values `json:"query"` // Header is only required when it is mandatory for reading the resource. Header url.Values `json:"header"` - CreateMethod *string `json:"create_method"` - UpdateMethod *string `json:"update_method"` - DeleteMethod *string `json:"delete_method"` - // Body represents the properties expected to be managed and tracked by Terraform. The value of these properties can be null as a place holder. // When absent, all the response payload read wil be set to `body`. - Body map[string]interface{} + Body json.RawMessage `json:"body"` } func (Resource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { @@ -1108,19 +1084,15 @@ func (Resource) ImportState(ctx context.Context, req resource.ImportStateRequest return } - var body string - if len(imp.Body) != 0 { - b, err := json.Marshal(imp.Body) - if err != nil { - resp.Diagnostics.AddError( - "Resource Import Error", - fmt.Sprintf("failed to marshal id.body: %v", err), - ) - return - } - body = string(b) + body, err := dynamic.FromJSONImplied(imp.Body) + if err != nil { + resp.Diagnostics.AddError( + "Resource Import Error", + fmt.Sprintf("unmarshal `body`: %v", err), + ) + return } - body = __IMPORT_HEADER__ + body + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, idPath, imp.Id)...) resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path, imp.Path)...) resp.Diagnostics.Append(resp.State.SetAttribute(ctx, bodyPath, body)...) From 263d28a5544508a5b61d0ec4facb2610c92c88fa Mon Sep 17 00:00:00 2001 From: magodo Date: Mon, 25 Mar 2024 13:07:15 +0800 Subject: [PATCH 04/24] Make output dynamic --- internal/provider/resource.go | 66 +++++++++++++++++------------------ 1 file changed, 32 insertions(+), 34 deletions(-) diff --git a/internal/provider/resource.go b/internal/provider/resource.go index 56fcb85..0774f43 100644 --- a/internal/provider/resource.go +++ b/internal/provider/resource.go @@ -71,9 +71,8 @@ type resourceData struct { CheckExistance types.Bool `tfsdk:"check_existance"` ForceNewAttrs types.Set `tfsdk:"force_new_attrs"` - OutputAttrs types.Set `tfsdk:"output_attrs"` - Output types.String `tfsdk:"output"` + Output types.Dynamic `tfsdk:"output"` } type pollData struct { @@ -455,13 +454,7 @@ func (r *Resource) Schema(ctx context.Context, req resource.SchemaRequest, resp Optional: true, ElementType: types.StringType, }, - "output_attrs": schema.SetAttribute{ - Description: "A set of `output` attribute paths (in gjson syntax) that will be exported in the `output`. If this is not specified, all attributes will be exported by `output`.", - MarkdownDescription: "A set of `output` attribute paths (in [gjson syntax](https://github.com/tidwall/gjson/blob/master/SYNTAX.md)) that will be exported in the `output`. If this is not specified, all attributes will be exported by `output`.", - Optional: true, - ElementType: types.StringType, - }, - "output": schema.StringAttribute{ + "output": schema.DynamicAttribute{ Description: "The response body after reading the resource.", MarkdownDescription: "The response body after reading the resource.", Computed: true, @@ -777,25 +770,15 @@ func (r Resource) Read(ctx context.Context, req resource.ReadRequest, resp *reso state.Body = body // Set output - output := string(b) - if !state.OutputAttrs.IsNull() { - // Update the output to only contain the specified attributes. - var outputAttrs []string - diags = state.OutputAttrs.ElementsAs(ctx, &outputAttrs, false) - resp.Diagnostics.Append(diags...) - if diags.HasError() { - return - } - output, err = FilterAttrsInJSON(output, outputAttrs) - if err != nil { - resp.Diagnostics.AddError( - "Filter `output` during Read", - err.Error(), - ) - return - } + output, err := dynamic.FromJSONImplied(b) + if err != nil { + resp.Diagnostics.AddError( + "Evaluating `output` during Read", + err.Error(), + ) + return } - state.Output = types.StringValue(output) + state.Output = output diags = resp.State.Set(ctx, state) resp.Diagnostics.Append(diags...) @@ -867,11 +850,19 @@ func (r Resource) Update(ctx context.Context, req resource.UpdateRequest, resp * path := plan.ID.ValueString() if !plan.UpdatePath.IsNull() { - path, err = buildpath.BuildPath(plan.UpdatePath.ValueString(), r.p.apiOpt.BaseURL.String(), plan.Path.ValueString(), []byte(state.Output.ValueString())) + output, err := dynamic.ToJSON(state.Output) if err != nil { resp.Diagnostics.AddError( - fmt.Sprintf("Failed to build the path for updating the resource"), - fmt.Sprintf("Can't build path with `update_path`: %q, `path`: %q, `body`: %q", plan.UpdatePath.ValueString(), plan.Path.ValueString(), string(state.Output.ValueString())), + "Failed to marshal json for `output`", + err.Error(), + ) + return + } + path, err = buildpath.BuildPath(plan.UpdatePath.ValueString(), r.p.apiOpt.BaseURL.String(), plan.Path.ValueString(), output) + if err != nil { + resp.Diagnostics.AddError( + "Failed to build the path for updating the resource", + fmt.Sprintf("Can't build path with `update_path`: %q, `path`: %q, `body`: %q", plan.UpdatePath.ValueString(), plan.Path.ValueString(), output), ) return } @@ -971,12 +962,19 @@ func (r Resource) Delete(ctx context.Context, req resource.DeleteRequest, resp * path := state.ID.ValueString() if !state.DeletePath.IsNull() { - var err error - path, err = buildpath.BuildPath(state.DeletePath.ValueString(), r.p.apiOpt.BaseURL.String(), state.Path.ValueString(), []byte(state.Output.ValueString())) + output, err := dynamic.ToJSON(state.Output) + if err != nil { + resp.Diagnostics.AddError( + "Failed to marshal json for `output`", + err.Error(), + ) + return + } + path, err = buildpath.BuildPath(state.DeletePath.ValueString(), r.p.apiOpt.BaseURL.String(), state.Path.ValueString(), output) if err != nil { resp.Diagnostics.AddError( - fmt.Sprintf("Failed to build the path for deleting the resource"), - fmt.Sprintf("Can't build path with `delete_path`: %q, `path`: %q, `body`: %q", state.DeletePath.ValueString(), state.Path.ValueString(), string(state.Output.ValueString())), + "Failed to build the path for deleting the resource", + fmt.Sprintf("Can't build path with `delete_path`: %q, `path`: %q, `body`: %q", state.DeletePath.ValueString(), state.Path.ValueString(), output), ) return } From 055137eebca404f091b212b47733bcfca0295dd7 Mon Sep 17 00:00:00 2001 From: magodo Date: Mon, 25 Mar 2024 14:39:15 +0800 Subject: [PATCH 05/24] Set `output` as unknown when `body` changes --- internal/provider/resource.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/internal/provider/resource.go b/internal/provider/resource.go index 0774f43..8332f4d 100644 --- a/internal/provider/resource.go +++ b/internal/provider/resource.go @@ -481,11 +481,22 @@ func (r *Resource) ModifyPlan(ctx context.Context, req resource.ModifyPlanReques // If the entire state is null, the resource is planned for creation. return } + var plan resourceData if diags := req.Plan.Get(ctx, &plan); diags.HasError() { resp.Diagnostics.Append(diags...) return } + var state resourceData + if diags := req.State.Get(ctx, &state); diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + defer func() { + resp.Plan.Set(ctx, plan) + }() + if !plan.ForceNewAttrs.IsUnknown() && !plan.Body.IsUnknown() { var forceNewAttrs []types.String if diags := plan.ForceNewAttrs.ElementsAs(ctx, &forceNewAttrs, false); diags != nil { @@ -537,6 +548,12 @@ func (r *Resource) ModifyPlan(ctx context.Context, req resource.ModifyPlanReques } } } + + if !plan.Body.Equal(state.Body) { + // Explicitly set the output as unknown dynamic to overwrite its dynamic type deduced from the prior state. + // Otherwise, if the output changed its type, ti will result into a data inconsistency error. + plan.Output = types.DynamicUnknown() + } } func (r *Resource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { From 63cdfa7244e198fec6920588f8c39d011e5eaf4b Mon Sep 17 00:00:00 2001 From: magodo Date: Mon, 25 Mar 2024 15:06:58 +0800 Subject: [PATCH 06/24] Add back output_attrs --- internal/provider/resource.go | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/internal/provider/resource.go b/internal/provider/resource.go index 8332f4d..d5cf527 100644 --- a/internal/provider/resource.go +++ b/internal/provider/resource.go @@ -71,6 +71,7 @@ type resourceData struct { CheckExistance types.Bool `tfsdk:"check_existance"` ForceNewAttrs types.Set `tfsdk:"force_new_attrs"` + OutputAttrs types.Set `tfsdk:"output_attrs"` Output types.Dynamic `tfsdk:"output"` } @@ -454,6 +455,11 @@ func (r *Resource) Schema(ctx context.Context, req resource.SchemaRequest, resp Optional: true, ElementType: types.StringType, }, + "output_attrs": schema.SetAttribute{Description: "A set of `output` attribute paths (in gjson syntax) that will be exported in the `output`. If this is not specified, all attributes will be exported by `output`.", + MarkdownDescription: "A set of `output` attribute paths (in [gjson syntax](https://github.com/tidwall/gjson/blob/master/SYNTAX.md)) that will be exported in the `output`. If this is not specified, all attributes will be exported by `output`.", + Optional: true, + ElementType: types.StringType, + }, "output": schema.DynamicAttribute{ Description: "The response body after reading the resource.", MarkdownDescription: "The response body after reading the resource.", @@ -549,7 +555,7 @@ func (r *Resource) ModifyPlan(ctx context.Context, req resource.ModifyPlanReques } } - if !plan.Body.Equal(state.Body) { + if !plan.Body.Equal(state.Body) || !plan.OutputAttrs.Equal(state.OutputAttrs) { // Explicitly set the output as unknown dynamic to overwrite its dynamic type deduced from the prior state. // Otherwise, if the output changed its type, ti will result into a data inconsistency error. plan.Output = types.DynamicUnknown() @@ -787,6 +793,24 @@ func (r Resource) Read(ctx context.Context, req resource.ReadRequest, resp *reso state.Body = body // Set output + if !state.OutputAttrs.IsNull() { + var outputAttrs []string + diags = state.OutputAttrs.ElementsAs(ctx, &outputAttrs, false) + resp.Diagnostics.Append(diags...) + if diags.HasError() { + return + } + fb, err := FilterAttrsInJSON(string(b), outputAttrs) + if err != nil { + resp.Diagnostics.AddError( + "Filter `output` during Read", + err.Error(), + ) + return + } + b = []byte(fb) + } + output, err := dynamic.FromJSONImplied(b) if err != nil { resp.Diagnostics.AddError( From 3ea29169a181aa9b5810cb5ac05a2f5f067198ca Mon Sep 17 00:00:00 2001 From: magodo Date: Wed, 27 Mar 2024 09:56:55 +0800 Subject: [PATCH 07/24] Update fw and remove the computed dynamic workaround --- go.mod | 2 +- go.sum | 4 ++-- internal/provider/resource.go | 6 ------ 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index c494a9c..5f586a8 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ toolchain go1.22.1 require ( github.com/evanphx/json-patch v0.5.2 github.com/go-resty/resty/v2 v2.10.0 - github.com/hashicorp/terraform-plugin-framework v1.7.0 + github.com/hashicorp/terraform-plugin-framework v1.7.1-0.20240326130300-484f311c99cf github.com/hashicorp/terraform-plugin-framework-validators v0.12.0 github.com/hashicorp/terraform-plugin-go v0.22.1 github.com/hashicorp/terraform-plugin-sdk/v2 v2.33.0 diff --git a/go.sum b/go.sum index 630392c..4795bfd 100644 --- a/go.sum +++ b/go.sum @@ -76,8 +76,8 @@ github.com/hashicorp/terraform-exec v0.20.0 h1:DIZnPsqzPGuUnq6cH8jWcPunBfY+C+M8J github.com/hashicorp/terraform-exec v0.20.0/go.mod h1:ckKGkJWbsNqFKV1itgMnE0hY9IYf1HoiekpuN0eWoDw= github.com/hashicorp/terraform-json v0.21.0 h1:9NQxbLNqPbEMze+S6+YluEdXgJmhQykRyRNd+zTI05U= github.com/hashicorp/terraform-json v0.21.0/go.mod h1:qdeBs11ovMzo5puhrRibdD6d2Dq6TyE/28JiU4tIQxk= -github.com/hashicorp/terraform-plugin-framework v1.7.0 h1:wOULbVmfONnJo9iq7/q+iBOBJul5vRovaYJIu2cY/Pw= -github.com/hashicorp/terraform-plugin-framework v1.7.0/go.mod h1:jY9Id+3KbZ17OMpulgnWLSfwxNVYSoYBQFTgsx044CI= +github.com/hashicorp/terraform-plugin-framework v1.7.1-0.20240326130300-484f311c99cf h1:pUx5HaXbPjLAhIO/vxisMrqDlalIUyQAxMDun0TKLBM= +github.com/hashicorp/terraform-plugin-framework v1.7.1-0.20240326130300-484f311c99cf/go.mod h1:jY9Id+3KbZ17OMpulgnWLSfwxNVYSoYBQFTgsx044CI= github.com/hashicorp/terraform-plugin-framework-validators v0.12.0 h1:HOjBuMbOEzl7snOdOoUfE2Jgeto6JOjLVQ39Ls2nksc= github.com/hashicorp/terraform-plugin-framework-validators v0.12.0/go.mod h1:jfHGE/gzjxYz6XoUwi/aYiiKrJDeutQNUtGQXkaHklg= github.com/hashicorp/terraform-plugin-go v0.22.1 h1:iTS7WHNVrn7uhe3cojtvWWn83cm2Z6ryIUDTRO0EV7w= diff --git a/internal/provider/resource.go b/internal/provider/resource.go index d5cf527..72cdb1f 100644 --- a/internal/provider/resource.go +++ b/internal/provider/resource.go @@ -554,12 +554,6 @@ func (r *Resource) ModifyPlan(ctx context.Context, req resource.ModifyPlanReques } } } - - if !plan.Body.Equal(state.Body) || !plan.OutputAttrs.Equal(state.OutputAttrs) { - // Explicitly set the output as unknown dynamic to overwrite its dynamic type deduced from the prior state. - // Otherwise, if the output changed its type, ti will result into a data inconsistency error. - plan.Output = types.DynamicUnknown() - } } func (r *Resource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { From 97074b2f58f5e4f255d51e85554511bf4a2d57c5 Mon Sep 17 00:00:00 2001 From: magodo Date: Fri, 29 Mar 2024 17:22:25 +0800 Subject: [PATCH 08/24] Update will check body change regardless of TF type --- internal/provider/resource.go | 35 ++++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/internal/provider/resource.go b/internal/provider/resource.go index 72cdb1f..4c28e51 100644 --- a/internal/provider/resource.go +++ b/internal/provider/resource.go @@ -845,8 +845,25 @@ func (r Resource) Update(ctx context.Context, req resource.UpdateRequest, resp * return } - // Invoke API to Update the resource only when there are changes in the body. - if !state.Body.Equal(plan.Body) { + stateBody, err := dynamic.ToJSON(state.Body) + if err != nil { + resp.Diagnostics.AddError( + "Update failure", + fmt.Sprintf("Error to marshal state body: %v", err), + ) + return + } + planBody, err := dynamic.ToJSON(plan.Body) + if err != nil { + resp.Diagnostics.AddError( + "Update failure", + fmt.Sprintf("Error to marshal plan body: %v", err), + ) + return + } + + // Invoke API to Update the resource only when there are changes in the body (regardless of the TF type diff). + if string(stateBody) != string(planBody) { // Precheck unlockFunc, diags := precheck(ctx, c, r.p.apiOpt, state.ID.ValueString(), opt.Header, opt.Query, plan.PrecheckUpdate) if diags.HasError() { @@ -855,14 +872,6 @@ func (r Resource) Update(ctx context.Context, req resource.UpdateRequest, resp * } defer unlockFunc() - body, err := dynamic.ToJSON(plan.Body) - if err != nil { - resp.Diagnostics.AddError( - "Update failure", - fmt.Sprintf("Error to marshal plan body: %v", err), - ) - return - } if opt.Method == "PATCH" && !opt.MergePatchDisabled { stateBodyJSON, err := dynamic.ToJSON(state.Body) if err != nil { @@ -872,7 +881,7 @@ func (r Resource) Update(ctx context.Context, req resource.UpdateRequest, resp * ) return } - b, err := jsonpatch.CreateMergePatch(body, stateBodyJSON) + b, err := jsonpatch.CreateMergePatch(planBody, stateBodyJSON) if err != nil { resp.Diagnostics.AddError( "Update failure", @@ -880,7 +889,7 @@ func (r Resource) Update(ctx context.Context, req resource.UpdateRequest, resp * ) return } - body = b + planBody = b } path := plan.ID.ValueString() @@ -903,7 +912,7 @@ func (r Resource) Update(ctx context.Context, req resource.UpdateRequest, resp * } } - response, err := c.Update(ctx, path, body, *opt) + response, err := c.Update(ctx, path, planBody, *opt) if err != nil { resp.Diagnostics.AddError( "Error to call update", From d9b94f44656d454199daba281dee34d6e90d5c37 Mon Sep 17 00:00:00 2001 From: magodo Date: Wed, 10 Apr 2024 15:55:55 +0800 Subject: [PATCH 09/24] `dynamic.FromJSON` - Handle the potential `foo = null` form that ends up to type dynamic --- internal/dynamic/dynamic.go | 5 ++++ internal/dynamic/dynamic_test.go | 51 +++++++++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/internal/dynamic/dynamic.go b/internal/dynamic/dynamic.go index 7b0401b..bc72d44 100644 --- a/internal/dynamic/dynamic.go +++ b/internal/dynamic/dynamic.go @@ -259,6 +259,11 @@ func attrValueFromJSON(b []byte, typ attr.Type) (attr.Value, error) { return nil, fmt.Errorf("%s: %s", diag.Summary(), diag.Detail()) } return vv, nil + case basetypes.DynamicType: + if b == nil || string(b) == "null" { + return types.DynamicNull(), nil + } + return FromJSONImplied(b) default: return nil, fmt.Errorf("Unhandled type: %T", typ) } diff --git a/internal/dynamic/dynamic_test.go b/internal/dynamic/dynamic_test.go index fea93eb..90ab2ab 100644 --- a/internal/dynamic/dynamic_test.go +++ b/internal/dynamic/dynamic_test.go @@ -205,7 +205,11 @@ func TestFromJSON(t *testing.T) { "bool": true, "string": "a" }, - "object_null": null + "object_null": null, + "dynamic": { + "foo": "bar" + }, + "dynamic_null": null }`, expect: types.DynamicValue( types.ObjectValueMust( @@ -262,6 +266,8 @@ func TestFromJSON(t *testing.T) { "string": types.StringType, }, }, + "dynamic": types.DynamicType, + "dynamic_null": types.DynamicType, }, map[string]attr.Value{ "bool": types.BoolValue(true), @@ -329,6 +335,17 @@ func TestFromJSON(t *testing.T) { "string": types.StringType, }, ), + "dynamic": types.DynamicValue( + types.ObjectValueMust( + map[string]attr.Type{ + "foo": types.StringType, + }, + map[string]attr.Value{ + "foo": types.StringValue("bar"), + }, + ), + ), + "dynamic_null": types.DynamicNull(), }, ), ), @@ -372,6 +389,38 @@ func TestFromJSON(t *testing.T) { ), ), }, + { + name: "tuple length changed", + input: ` +[{ + "str1": "a" +}] +`, + expect: types.DynamicValue( + types.TupleValueMust( + []attr.Type{ + types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "str1": types.StringType, + "str2": types.StringType, + }, + }, + }, + []attr.Value{ + types.ObjectValueMust( + map[string]attr.Type{ + "str1": types.StringType, + "str2": types.StringType, + }, + map[string]attr.Value{ + "str1": types.StringValue("a"), + "str2": types.StringNull(), + }, + ), + }, + ), + ), + }, } for _, tt := range cases { From 42e33c72f918d933aef42840b535c205b1f1e490 Mon Sep 17 00:00:00 2001 From: magodo Date: Wed, 10 Apr 2024 15:56:25 +0800 Subject: [PATCH 10/24] Read on refresh handle potential type mismatch between state vs remote --- internal/provider/resource.go | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/internal/provider/resource.go b/internal/provider/resource.go index 4c28e51..c04c473 100644 --- a/internal/provider/resource.go +++ b/internal/provider/resource.go @@ -774,13 +774,17 @@ func (r Resource) Read(ctx context.Context, req resource.ReadRequest, resp *reso } var body types.Dynamic - body, err = dynamic.FromJSON(b, state.Body.UnderlyingValue().Type(ctx)) - if err != nil { - resp.Diagnostics.AddError( - "Evaluating `body` during Read", - err.Error(), - ) - return + if body, err = dynamic.FromJSON(b, state.Body.UnderlyingValue().Type(ctx)); err != nil { + // An error might occur here during refresh, when the type of the state doesn't match the remote, + // e.g. a tuple field has different number of elements. + // In this case, we fallback to the implied types, to make the refresh proceed and return a reasonable plan diff. + if body, err = dynamic.FromJSONImplied(b); err != nil { + resp.Diagnostics.AddError( + "Evaluating `body` during Read", + err.Error(), + ) + return + } } // Set body, which is modified during read. From 0b6645c5e5ddbedc2bb850bddbf2bdef62bac44c Mon Sep 17 00:00:00 2001 From: magodo Date: Wed, 10 Apr 2024 17:47:27 +0800 Subject: [PATCH 11/24] Support ds and operation res, update test. TODO: res set state in create/update separately --- internal/dynamic/dynamic.go | 3 + internal/provider/data_source.go | 37 +++--- .../provider/data_source_jsonserver_test.go | 21 ++-- internal/provider/data_source_mtls_test.go | 2 +- .../provider/operation_jsonserver_test.go | 22 ++-- internal/provider/operation_resource.go | 102 +++++++++------ internal/provider/resource.go | 2 +- internal/provider/resource_azure_test.go | 118 +++++++++++------- internal/provider/resource_jsonserver_test.go | 35 +++--- internal/provider/resource_msgraph_test.go | 38 +++--- 10 files changed, 226 insertions(+), 154 deletions(-) diff --git a/internal/dynamic/dynamic.go b/internal/dynamic/dynamic.go index bc72d44..9a763a7 100644 --- a/internal/dynamic/dynamic.go +++ b/internal/dynamic/dynamic.go @@ -11,6 +11,9 @@ import ( ) func ToJSON(d types.Dynamic) ([]byte, error) { + if d.IsNull() { + return nil, nil + } return attrValueToJSON(d.UnderlyingValue()) } diff --git a/internal/provider/data_source.go b/internal/provider/data_source.go index b172971..2cd5ed6 100644 --- a/internal/provider/data_source.go +++ b/internal/provider/data_source.go @@ -10,6 +10,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/datasource/schema" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/magodo/terraform-provider-restful/internal/dynamic" "github.com/tidwall/gjson" ) @@ -20,16 +21,16 @@ type DataSource struct { var _ datasource.DataSource = &DataSource{} type dataSourceData struct { - ID types.String `tfsdk:"id"` - Method types.String `tfsdk:"method"` - Query types.Map `tfsdk:"query"` - Header types.Map `tfsdk:"header"` - Selector types.String `tfsdk:"selector"` - OutputAttrs types.Set `tfsdk:"output_attrs"` - AllowNotExist types.Bool `tfsdk:"allow_not_exist"` - Precheck types.List `tfsdk:"precheck"` - Retry types.Object `tfsdk:"retry"` - Output types.String `tfsdk:"output"` + ID types.String `tfsdk:"id"` + Method types.String `tfsdk:"method"` + Query types.Map `tfsdk:"query"` + Header types.Map `tfsdk:"header"` + Selector types.String `tfsdk:"selector"` + OutputAttrs types.Set `tfsdk:"output_attrs"` + AllowNotExist types.Bool `tfsdk:"allow_not_exist"` + Precheck types.List `tfsdk:"precheck"` + Retry types.Object `tfsdk:"retry"` + Output types.Dynamic `tfsdk:"output"` } func (d *DataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { @@ -84,7 +85,7 @@ func (d *DataSource) Schema(ctx context.Context, req datasource.SchemaRequest, r }, "precheck": precheckAttribute("Read", true, ""), "retry": retryAttribute("Read"), - "output": schema.StringAttribute{ + "output": schema.DynamicAttribute{ Description: "The response body after reading the resource.", MarkdownDescription: "The response body after reading the resource.", Computed: true, @@ -190,7 +191,6 @@ func (d *DataSource) Read(ctx context.Context, req datasource.ReadRequest, resp } // Set output - output := string(b) if !config.OutputAttrs.IsNull() { // Update the output to only contain the specified attributes. var outputAttrs []string @@ -199,7 +199,7 @@ func (d *DataSource) Read(ctx context.Context, req datasource.ReadRequest, resp if diags.HasError() { return } - output, err = FilterAttrsInJSON(output, outputAttrs) + fb, err := FilterAttrsInJSON(string(b), outputAttrs) if err != nil { resp.Diagnostics.AddError( "Filter `output` during Read", @@ -207,9 +207,18 @@ func (d *DataSource) Read(ctx context.Context, req datasource.ReadRequest, resp ) return } + b = []byte(fb) } - state.Output = types.StringValue(output) + output, err := dynamic.FromJSONImplied(b) + if err != nil { + resp.Diagnostics.AddError( + "Evaluating `output` during Read", + err.Error(), + ) + return + } + state.Output = output diags = resp.State.Set(ctx, state) resp.Diagnostics.Append(diags...) diff --git a/internal/provider/data_source_jsonserver_test.go b/internal/provider/data_source_jsonserver_test.go index baafa21..26a6000 100644 --- a/internal/provider/data_source_jsonserver_test.go +++ b/internal/provider/data_source_jsonserver_test.go @@ -20,7 +20,7 @@ func TestDataSource_JSONServer_Basic(t *testing.T) { { Config: d.dsBasic(), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttrSet(dsaddr, "output"), + resource.TestCheckResourceAttrSet(dsaddr, "output.%"), ), }, }, @@ -39,7 +39,7 @@ func TestDataSource_JSONServer_WithSelector(t *testing.T) { { Config: d.dsWithSelector(), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttrSet(dsaddr, "output"), + resource.TestCheckResourceAttrSet(dsaddr, "output.%"), ), }, }, @@ -58,7 +58,8 @@ func TestDataSource_JSONServer_WithOutputAttrs(t *testing.T) { { Config: d.dsWithOutputAttrs(), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttrWith(dsaddr, "output", CheckJSONEqual("output", `{"foo": "bar", "obj": {"a": 1}}`)), + resource.TestCheckResourceAttr(dsaddr, "output.foo", "bar"), + resource.TestCheckResourceAttr(dsaddr, "output.obj.a", "1"), ), }, }, @@ -76,7 +77,7 @@ func TestDataSource_JSONServer_NotExists(t *testing.T) { Config: d.dsNotExist(), Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttrSet(dsaddr, "id"), - resource.TestCheckNoResourceAttr(dsaddr, "output"), + resource.TestCheckNoResourceAttr(dsaddr, "output.%"), ), }, }, @@ -91,9 +92,9 @@ provider "restful" { resource "restful_resource" "test" { path = "/posts" - body = jsonencode({ + body = { foo = "bar" -}) + } read_path = "$(path)/$(body.id)" } @@ -112,9 +113,9 @@ provider "restful" { resource "restful_resource" "test" { path = "/posts" - body = jsonencode({ + body = { foo = "bar" -}) + } read_path = "$(path)/$(body.id)" } @@ -135,13 +136,13 @@ provider "restful" { resource "restful_resource" "test" { path = "/posts" - body = jsonencode({ + body = { foo = "bar" obj = { a = 1 b = 2 } -}) + } read_path = "$(path)/$(body.id)" } diff --git a/internal/provider/data_source_mtls_test.go b/internal/provider/data_source_mtls_test.go index bd33776..46ab198 100644 --- a/internal/provider/data_source_mtls_test.go +++ b/internal/provider/data_source_mtls_test.go @@ -25,7 +25,7 @@ func TestDataSourceMTLS(t *testing.T) { serverTLSConfig, caCert, clientCert, clientKey, err := certSetup() require.NoError(t, err) - resp := "response" + resp := "{}" server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, resp) })) diff --git a/internal/provider/operation_jsonserver_test.go b/internal/provider/operation_jsonserver_test.go index fcaded1..7ac71a8 100644 --- a/internal/provider/operation_jsonserver_test.go +++ b/internal/provider/operation_jsonserver_test.go @@ -36,7 +36,7 @@ func TestOperation_JSONServer_Basic(t *testing.T) { { Config: d.basic(), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttrSet(addr, "output"), + resource.TestCheckResourceAttrSet(addr, "output.%"), ), }, }, @@ -54,8 +54,8 @@ func TestOperation_JSONServer_withDelete(t *testing.T) { { Config: d.withDelete(true), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttrSet(addr, "output"), - resource.TestCheckResourceAttr(addr, "output", `{"enabled":true}`), + resource.TestCheckResourceAttrSet(addr, "output.%"), + resource.TestCheckResourceAttr(addr, "output.enabled", `true`), ), }, { @@ -66,7 +66,7 @@ func TestOperation_JSONServer_withDelete(t *testing.T) { { Config: d.withDelete(false), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr(resaddr, "output", `{"enabled":false}`), + resource.TestCheckResourceAttr(resaddr, "output.enabled", `false`), ), }, }, @@ -82,9 +82,9 @@ provider "restful" { resource "restful_operation" "test" { path = "posts" method = "POST" - body = jsonencode({ + body = { foo = "bar" - }) + } } `, d.url) } @@ -98,7 +98,7 @@ provider "restful" { # This resource is used to check the state of the posts after the operation resource is deleted resource "restful_resource" "test" { path = "posts" - body = jsonencode({}) + body = {} read_path = "$(path)/$(body.id)" output_attrs = ["enabled"] } @@ -109,14 +109,14 @@ resource "restful_resource" "test" { resource "restful_operation" "test" { path = restful_resource.test.id method = "PUT" - body = jsonencode({ + body = { enabled = true - }) + } delete_method = "PUT" delete_path = restful_resource.test.id - delete_body = jsonencode({ + delete_body = { enabled = false - }) + } output_attrs = ["enabled"] }` } diff --git a/internal/provider/operation_resource.go b/internal/provider/operation_resource.go index 4ab31d3..dd2aab6 100644 --- a/internal/provider/operation_resource.go +++ b/internal/provider/operation_resource.go @@ -19,6 +19,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/types/basetypes" "github.com/magodo/terraform-provider-restful/internal/buildpath" "github.com/magodo/terraform-provider-restful/internal/client" + "github.com/magodo/terraform-provider-restful/internal/dynamic" myvalidator "github.com/magodo/terraform-provider-restful/internal/validator" ) @@ -29,23 +30,23 @@ type OperationResource struct { var _ resource.Resource = &OperationResource{} type operationResourceData struct { - ID types.String `tfsdk:"id"` - Path types.String `tfsdk:"path"` - Method types.String `tfsdk:"method"` - Body types.String `tfsdk:"body"` - Query types.Map `tfsdk:"query"` - Header types.Map `tfsdk:"header"` - Precheck types.List `tfsdk:"precheck"` - Poll types.Object `tfsdk:"poll"` - Retry types.Object `tfsdk:"retry"` - DeleteMethod types.String `tfsdk:"delete_method"` - DeleteBody types.String `tfsdk:"delete_body"` - DeletePath types.String `tfsdk:"delete_path"` - PrecheckDelete types.List `tfsdk:"precheck_delete"` - PollDelete types.Object `tfsdk:"poll_delete"` - RetryDelete types.Object `tfsdk:"retry_delete"` - OutputAttrs types.Set `tfsdk:"output_attrs"` - Output types.String `tfsdk:"output"` + ID types.String `tfsdk:"id"` + Path types.String `tfsdk:"path"` + Method types.String `tfsdk:"method"` + Body types.Dynamic `tfsdk:"body"` + Query types.Map `tfsdk:"query"` + Header types.Map `tfsdk:"header"` + Precheck types.List `tfsdk:"precheck"` + Poll types.Object `tfsdk:"poll"` + Retry types.Object `tfsdk:"retry"` + DeleteMethod types.String `tfsdk:"delete_method"` + DeleteBody types.Dynamic `tfsdk:"delete_body"` + DeletePath types.String `tfsdk:"delete_path"` + PrecheckDelete types.List `tfsdk:"precheck_delete"` + PollDelete types.Object `tfsdk:"poll_delete"` + RetryDelete types.Object `tfsdk:"retry_delete"` + OutputAttrs types.Set `tfsdk:"output_attrs"` + Output types.Dynamic `tfsdk:"output"` } func (r *OperationResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { @@ -88,13 +89,10 @@ func (r *OperationResource) Schema(ctx context.Context, req resource.SchemaReque stringvalidator.OneOf("PUT", "POST", "PATCH", "DELETE"), }, }, - "body": schema.StringAttribute{ + "body": schema.DynamicAttribute{ Description: "The payload for the `Create`/`Update` call.", MarkdownDescription: "The payload for the `Create`/`Update` call.", Optional: true, - Validators: []validator.String{ - myvalidator.StringIsJSON(), - }, }, "query": schema.MapAttribute{ Description: "The query parameters that are applied to each request. This overrides the `query` set in the provider block.", @@ -132,14 +130,10 @@ func (r *OperationResource) Schema(ctx context.Context, req resource.SchemaReque }, }, - "delete_body": schema.StringAttribute{ + "delete_body": schema.DynamicAttribute{ Description: "The payload for the `Delete` call.", MarkdownDescription: "The payload for the `Delete` call.", Optional: true, - Validators: []validator.String{ - stringvalidator.AlsoRequires(path.MatchRelative().AtParent().AtName("delete_method")), - myvalidator.StringIsJSON(), - }, }, "precheck_delete": precheckDelete, @@ -153,7 +147,7 @@ func (r *OperationResource) Schema(ctx context.Context, req resource.SchemaReque ElementType: types.StringType, }, - "output": schema.StringAttribute{ + "output": schema.DynamicAttribute{ Description: "The response body.", MarkdownDescription: "The response body.", Computed: true, @@ -205,7 +199,16 @@ func (r *OperationResource) createOrUpdate(ctx context.Context, tfplan tfsdk.Pla } defer unlockFunc() - response, err := c.Operation(ctx, plan.Path.ValueString(), plan.Body.ValueString(), *opt) + b, err := dynamic.ToJSON(plan.Body) + if err != nil { + diagnostics.AddError( + "Error to marshal body", + err.Error(), + ) + return + } + + response, err := c.Operation(ctx, plan.Path.ValueString(), string(b), *opt) if err != nil { diagnostics.AddError( "Error to call operation", @@ -221,8 +224,6 @@ func (r *OperationResource) createOrUpdate(ctx context.Context, tfplan tfsdk.Pla return } - b := response.Body() - resourceId := plan.Path.ValueString() // For LRO, wait for completion @@ -258,8 +259,7 @@ func (r *OperationResource) createOrUpdate(ctx context.Context, tfplan tfsdk.Pla plan.ID = types.StringValue(resourceId) // Set Output to state - plan.Output = types.StringValue(string(b)) - output := string(b) + rb := response.Body() if !plan.OutputAttrs.IsNull() { // Update the output to only contain the specified attributes. var outputAttrs []string @@ -268,7 +268,7 @@ func (r *OperationResource) createOrUpdate(ctx context.Context, tfplan tfsdk.Pla if diags.HasError() { return } - output, err = FilterAttrsInJSON(output, outputAttrs) + fb, err := FilterAttrsInJSON(string(rb), outputAttrs) if err != nil { diagnostics.AddError( "Filter `output` during operation", @@ -276,8 +276,18 @@ func (r *OperationResource) createOrUpdate(ctx context.Context, tfplan tfsdk.Pla ) return } + rb = []byte(fb) } - plan.Output = types.StringValue(output) + + output, err := dynamic.FromJSONImplied(rb) + if err != nil { + diagnostics.AddError( + "Evaluating `output` during Read", + err.Error(), + ) + return + } + plan.Output = output diags = state.Set(ctx, plan) diagnostics.Append(diags...) @@ -330,18 +340,34 @@ func (r *OperationResource) Delete(ctx context.Context, req resource.DeleteReque path := state.ID.ValueString() if !state.DeletePath.IsNull() { - var err error - path, err = buildpath.BuildPath(state.DeletePath.ValueString(), r.p.apiOpt.BaseURL.String(), state.Path.ValueString(), []byte(state.Output.ValueString())) + body, err := dynamic.ToJSON(state.Output) + if err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("Failed to build the path for deleting the operation resource"), + fmt.Sprintf("Failed to marshal the output: %v", err), + ) + return + } + path, err = buildpath.BuildPath(state.DeletePath.ValueString(), r.p.apiOpt.BaseURL.String(), state.Path.ValueString(), body) if err != nil { resp.Diagnostics.AddError( fmt.Sprintf("Failed to build the path for deleting the operation resource"), - fmt.Sprintf("Can't build path with `delete_path`: %q, `path`: %q, `body`: %q", state.DeletePath.ValueString(), state.Path.ValueString(), string(state.Output.ValueString())), + fmt.Sprintf("Can't build path with `delete_path`: %q, `path`: %q, `body`: %q, error: %v", state.DeletePath.ValueString(), state.Path.ValueString(), string(body), err), ) return } } - response, err := c.Operation(ctx, path, state.DeleteBody.ValueString(), *opt) + b, err := dynamic.ToJSON(state.DeleteBody) + if err != nil { + resp.Diagnostics.AddError( + "Error to marshal delete body", + err.Error(), + ) + return + } + + response, err := c.Operation(ctx, path, string(b), *opt) if err != nil { resp.Diagnostics.AddError( "Delete: Error to call operation", diff --git a/internal/provider/resource.go b/internal/provider/resource.go index c04c473..4fdcfba 100644 --- a/internal/provider/resource.go +++ b/internal/provider/resource.go @@ -885,7 +885,7 @@ func (r Resource) Update(ctx context.Context, req resource.UpdateRequest, resp * ) return } - b, err := jsonpatch.CreateMergePatch(planBody, stateBodyJSON) + b, err := jsonpatch.CreateMergePatch(stateBodyJSON, planBody) if err != nil { resp.Diagnostics.AddError( "Update failure", diff --git a/internal/provider/resource_azure_test.go b/internal/provider/resource_azure_test.go index dd6ef82..9e7f873 100644 --- a/internal/provider/resource_azure_test.go +++ b/internal/provider/resource_azure_test.go @@ -67,7 +67,7 @@ func TestResource_Azure_ResourceGroup(t *testing.T) { { Config: d.resourceGroup(), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttrSet(addr, "output"), + resource.TestCheckResourceAttrSet(addr, "output.%"), ), }, { @@ -80,7 +80,7 @@ func TestResource_Azure_ResourceGroup(t *testing.T) { { Config: d.resourceGroup_complete(), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttrSet(addr, "output"), + resource.TestCheckResourceAttrSet(addr, "output.%"), ), }, { @@ -88,7 +88,7 @@ func TestResource_Azure_ResourceGroup(t *testing.T) { ImportState: true, ImportStateVerify: true, ImportStateVerifyIgnore: []string{"poll_delete", "create_method"}, - ImportStateIdFunc: d.resourceGroupImportStateIdFunc(addr), + ImportStateIdFunc: d.resourceGroupCompleteImportStateIdFunc(addr), }, }, }) @@ -105,7 +105,7 @@ func TestResource_Azure_ResourceGroup_updatePath(t *testing.T) { { Config: d.resourceGroup_updatePath(), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttrSet(addr, "output"), + resource.TestCheckResourceAttrSet(addr, "output.%"), ), }, { @@ -118,7 +118,7 @@ func TestResource_Azure_ResourceGroup_updatePath(t *testing.T) { { Config: d.resourceGroup_updatePath_complete(), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttrSet(addr, "output"), + resource.TestCheckResourceAttrSet(addr, "output.%"), ), }, { @@ -126,7 +126,7 @@ func TestResource_Azure_ResourceGroup_updatePath(t *testing.T) { ImportState: true, ImportStateVerify: true, ImportStateVerifyIgnore: []string{"poll_delete", "create_method", "update_path"}, - ImportStateIdFunc: d.resourceGroupUpdatePathImportStateIdFunc(addr), + ImportStateIdFunc: d.resourceGroupUpdatePathCompleteImportStateIdFunc(addr), }, }, }) @@ -143,7 +143,7 @@ func TestResource_Azure_VirtualNetwork(t *testing.T) { { Config: d.vnet("foo"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttrSet(addr, "output"), + resource.TestCheckResourceAttrSet(addr, "output.%"), ), }, { @@ -156,7 +156,7 @@ func TestResource_Azure_VirtualNetwork(t *testing.T) { { Config: d.vnet("bar"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttrSet(addr, "output"), + resource.TestCheckResourceAttrSet(addr, "output.%"), ), }, { @@ -181,7 +181,7 @@ func TestResource_Azure_VirtualNetwork_Precheck(t *testing.T) { { Config: d.vnet_precheck("foo"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttrSet(addr, "output"), + resource.TestCheckResourceAttrSet(addr, "output.%"), ), }, { @@ -194,7 +194,7 @@ func TestResource_Azure_VirtualNetwork_Precheck(t *testing.T) { { Config: d.vnet_precheck("bar"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttrSet(addr, "output"), + resource.TestCheckResourceAttrSet(addr, "output.%"), ), }, { @@ -219,7 +219,7 @@ func TestResource_Azure_VirtualNetwork_SimplePoll(t *testing.T) { { Config: d.vnet_simple_poll("foo"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttrSet(addr, "output"), + resource.TestCheckResourceAttrSet(addr, "output.%"), ), }, { @@ -232,7 +232,7 @@ func TestResource_Azure_VirtualNetwork_SimplePoll(t *testing.T) { { Config: d.vnet_simple_poll("bar"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttrSet(addr, "output"), + resource.TestCheckResourceAttrSet(addr, "output.%"), ), }, { @@ -257,7 +257,7 @@ func TestResource_Azure_RouteTable_Precheck(t *testing.T) { { Config: d.routetable_precheck("foo"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttrSet(addr, "output"), + resource.TestCheckResourceAttrSet(addr, "output.%"), ), }, { @@ -270,7 +270,7 @@ func TestResource_Azure_RouteTable_Precheck(t *testing.T) { { Config: d.routetable_precheck("bar"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttrSet(addr, "output"), + resource.TestCheckResourceAttrSet(addr, "output.%"), ), }, { @@ -294,13 +294,13 @@ func TestOperationResource_Azure_Register_RP(t *testing.T) { { Config: d.unregisterRP("Microsoft.ProviderHub"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttrSet(addr, "output"), + resource.TestCheckResourceAttrSet(addr, "output.%"), ), }, { Config: d.registerRP("Microsoft.ProviderHub"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttrSet(addr, "output"), + resource.TestCheckResourceAttrSet(addr, "output.%"), ), }, }, @@ -358,9 +358,9 @@ resource "restful_resource" "test" { query = { api-version = ["2020-06-01"] } - body = jsonencode({ + body = { location = "westeurope" - }) + } create_method = "PUT" @@ -396,12 +396,12 @@ resource "restful_resource" "test" { query = { api-version = ["2020-06-01"] } - body = jsonencode({ + body = { location = "westeurope" tags = { foo = "bar" } - }) + } create_method = "PUT" @@ -438,9 +438,9 @@ resource "restful_resource" "test" { query = { api-version = ["2020-06-01"] } - body = jsonencode({ + body = { location = "westeurope" - }) + } create_method = "PUT" @@ -479,12 +479,12 @@ resource "restful_resource" "test" { query = { api-version = ["2020-06-01"] } - body = jsonencode({ + body = { location = "westeurope" tags = { foo = "bar" } - }) + } create_method = "PUT" @@ -510,7 +510,21 @@ func (d azureData) resourceGroupImportStateIdFunc(addr string) func(s *terraform "api-version": ["2020-06-01"] }, "path": %[1]q, -"create_method": "PUT", +"body": { + "location": null +} +}`, s.RootModule().Resources[addr].Primary.Attributes["id"]), nil + } +} + +func (d azureData) resourceGroupCompleteImportStateIdFunc(addr string) func(s *terraform.State) (string, error) { + return func(s *terraform.State) (string, error) { + return fmt.Sprintf(`{ +"id": %[1]q, +"query": { + "api-version": ["2020-06-01"] +}, +"path": %[1]q, "body": { "location": null, "tags": null @@ -526,7 +540,22 @@ func (d azureData) resourceGroupUpdatePathImportStateIdFunc(addr string) func(s "query": { "api-version": ["2020-06-01"] }, -"create_method": "PUT", +"path": %[1]q, +"update_path": %[1]q, +"body": { + "location": null +} +}`, s.RootModule().Resources[addr].Primary.Attributes["id"]), nil + } +} + +func (d azureData) resourceGroupUpdatePathCompleteImportStateIdFunc(addr string) func(s *terraform.State) (string, error) { + return func(s *terraform.State) (string, error) { + return fmt.Sprintf(`{ +"id": %[1]q, +"query": { + "api-version": ["2020-06-01"] +}, "path": %[1]q, "update_path": %[1]q, "body": { @@ -567,7 +596,6 @@ func (d azureData) routeImportStateIdFunc(addr string) func(s *terraform.State) }, "path": %[1]q, "body": { - "location": null, "properties": { "addressPrefix": null, "nextHopType": null @@ -599,9 +627,9 @@ resource "restful_resource" "rg" { query = { api-version = ["2020-06-01"] } - body = jsonencode({ + body = { location = "westeurope" - }) + } poll_delete = { status_locator = "code" @@ -641,7 +669,7 @@ resource "restful_resource" "test" { poll_update = local.vnet_poll poll_delete = local.vnet_poll - body = jsonencode({ + body = { location = "westus" properties = { addressSpace = { @@ -651,7 +679,7 @@ resource "restful_resource" "test" { tags = { foo = "%s" } - }) + } } `, d.vnet_template(), d.rd, tag) } @@ -679,9 +707,9 @@ resource "restful_resource" "rg" { query = { api-version = ["2020-06-01"] } - body = jsonencode({ + body = { location = "westeurope" - }) + } poll_delete = { status_locator = "code" @@ -732,7 +760,7 @@ resource "restful_resource" "test" { poll_update = local.vnet_poll poll_delete = local.vnet_poll - body = jsonencode({ + body = { location = "westus" properties = { addressSpace = { @@ -742,7 +770,7 @@ resource "restful_resource" "test" { tags = { foo = "%s" } - }) + } } `, d.url, d.clientId, d.clientSecret, d.tenantId, d.subscriptionId, d.rd, d.rd, tag) } @@ -779,7 +807,7 @@ resource "restful_resource" "test" { } } - body = jsonencode({ + body = { location = "westus" properties = { addressSpace = { @@ -789,7 +817,7 @@ resource "restful_resource" "test" { tags = { foo = "%s" } - }) + } } `, d.vnet_template(), d.rd, tag) } @@ -816,9 +844,9 @@ resource "restful_resource" "rg" { query = { api-version = ["2020-06-01"] } - body = jsonencode({ + body = { location = "westeurope" - }) + } poll_delete = { status_locator = "code" @@ -854,12 +882,12 @@ resource "restful_resource" "table" { query = { api-version = ["2022-07-01"] } - body = jsonencode({ + body = { location = "westus" tags = { foo = "%s" } - }) + } poll_create = local.poll poll_delete = local.poll } @@ -878,12 +906,12 @@ resource "restful_resource" "route1" { poll_update = local.poll poll_delete = local.poll - body = jsonencode({ + body = { properties = { nextHopType = "VnetLocal" addressPrefix = "10.1.0.0/16" } - }) + } } resource "restful_resource" "route2" { @@ -900,12 +928,12 @@ resource "restful_resource" "route2" { poll_update = local.poll poll_delete = local.poll - body = jsonencode({ + body = { properties = { nextHopType = "VnetLocal" addressPrefix = "10.2.0.0/16" } - }) + } } `, d.url, d.clientId, d.clientSecret, d.tenantId, d.subscriptionId, d.rd, d.rd, tag) } diff --git a/internal/provider/resource_jsonserver_test.go b/internal/provider/resource_jsonserver_test.go index 0393e28..3ba357e 100644 --- a/internal/provider/resource_jsonserver_test.go +++ b/internal/provider/resource_jsonserver_test.go @@ -43,7 +43,7 @@ func TestResource_JSONServer_Basic(t *testing.T) { { Config: d.basic("foo"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttrSet(addr, "output"), + resource.TestCheckResourceAttrSet(addr, "output.%"), ), }, { @@ -58,7 +58,7 @@ func TestResource_JSONServer_Basic(t *testing.T) { { Config: d.basic("bar"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttrSet(addr, "output"), + resource.TestCheckResourceAttrSet(addr, "output.%"), ), }, { @@ -85,7 +85,7 @@ func TestResource_JSONServer_PatchUpdate(t *testing.T) { { Config: d.patch("foo"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttrSet(addr, "output"), + resource.TestCheckResourceAttrSet(addr, "output.%"), ), }, { @@ -100,7 +100,7 @@ func TestResource_JSONServer_PatchUpdate(t *testing.T) { { Config: d.patch("bar"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttrSet(addr, "output"), + resource.TestCheckResourceAttrSet(addr, "output.%"), ), }, { @@ -127,7 +127,7 @@ func TestResource_JSONServer_FullPath(t *testing.T) { { Config: d.fullPath("foo"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttrSet(addr, "output"), + resource.TestCheckResourceAttrSet(addr, "output.%"), ), }, { @@ -136,13 +136,13 @@ func TestResource_JSONServer_FullPath(t *testing.T) { ImportStateVerify: true, ImportStateVerifyIgnore: []string{"read_path", "update_path", "delete_path"}, ImportStateIdFunc: func(s *terraform.State) (string, error) { - return fmt.Sprintf(`{"id": %q, "path": "posts", "update_path": "$(path)/$(body.id)", "delete_path": "$(path)/$(body.id)", "body": {"foo": null}}`, s.RootModule().Resources[addr].Primary.Attributes["id"]), nil + return fmt.Sprintf(`{"id": %q, "path": "posts", "body": {"foo": null}}`, s.RootModule().Resources[addr].Primary.Attributes["id"]), nil }, }, { Config: d.fullPath("bar"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttrSet(addr, "output"), + resource.TestCheckResourceAttrSet(addr, "output.%"), ), }, { @@ -151,7 +151,7 @@ func TestResource_JSONServer_FullPath(t *testing.T) { ImportStateVerify: true, ImportStateVerifyIgnore: []string{"read_path", "update_path", "delete_path"}, ImportStateIdFunc: func(s *terraform.State) (string, error) { - return fmt.Sprintf(`{"id": %q, "path": "posts", "update_path": "$(path)/$(body.id)", "delete_path": "$(path)/$(body.id)", "body": {"foo": null}}`, s.RootModule().Resources[addr].Primary.Attributes["id"]), nil + return fmt.Sprintf(`{"id": %q, "path": "posts", "body": {"foo": null}}`, s.RootModule().Resources[addr].Primary.Attributes["id"]), nil }, }, }, @@ -169,7 +169,8 @@ func TestResource_JSONServer_OutputAttrs(t *testing.T) { { Config: d.outputAttrs(), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttrWith(addr, "output", CheckJSONEqual("output", `{"foo": "bar", "obj": {"a": 1}}`)), + resource.TestCheckResourceAttr(addr, "output.foo", "bar"), + resource.TestCheckResourceAttr(addr, "output.obj.a", "1"), ), }, }, @@ -207,9 +208,9 @@ provider "restful" { resource "restful_resource" "test" { path = "posts" - body = jsonencode({ + body = { foo = %q -}) + } read_path = "$(path)/$(body.id)" } `, d.url, v) @@ -225,9 +226,9 @@ resource "restful_resource" "test" { path = "posts" read_path = "$(path)/$(body.id)" update_method = "PATCH" - body = jsonencode({ + body = { foo = %q -}) + } } `, d.url, v) } @@ -243,9 +244,9 @@ resource "restful_resource" "test" { read_path = "$(path)/$(body.id)" update_path = "$(path)/$(body.id)" delete_path = "$(path)/$(body.id)" - body = jsonencode({ + body = { foo = %q -}) + } } `, d.url, v) @@ -259,13 +260,13 @@ provider "restful" { resource "restful_resource" "test" { path = "posts" - body = jsonencode({ + body = { foo = "bar" obj = { a = 1 b = 2 } -}) + } read_path = "$(path)/$(body.id)" output_attrs = ["foo", "obj.a"] } diff --git a/internal/provider/resource_msgraph_test.go b/internal/provider/resource_msgraph_test.go index 1f36036..980a846 100644 --- a/internal/provider/resource_msgraph_test.go +++ b/internal/provider/resource_msgraph_test.go @@ -67,7 +67,7 @@ func TestResource_MsGraph_User(t *testing.T) { { Config: d.user(false), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttrSet(addr, "output"), + resource.TestCheckResourceAttrSet(addr, "output.%"), ), }, { @@ -79,7 +79,7 @@ func TestResource_MsGraph_User(t *testing.T) { { Config: d.userUpdate(false), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttrSet(addr, "output"), + resource.TestCheckResourceAttrSet(addr, "output.%"), ), }, { @@ -91,7 +91,7 @@ func TestResource_MsGraph_User(t *testing.T) { { Config: d.user(true), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttrSet(addr, "output"), + resource.TestCheckResourceAttrSet(addr, "output.%"), ), }, { @@ -154,7 +154,7 @@ resource "restful_resource" "test" { path = "/users" read_path = "$(path)/$(body.id)" merge_patch_disabled = %t - body = jsonencode({ + body = { accountEnabled = true mailNickname = "AdeleV" passwordProfile = { @@ -163,12 +163,14 @@ resource "restful_resource" "test" { displayName = "J.Doe" userPrincipalName = "%d@%s" - }) - write_only_attrs = [ - "mailNickname", - "accountEnabled", - "passwordProfile", - ] + } + lifecycle { + ignore_changes = [ + "body.mailNickname", + "body.accountEnabled", + "body.passwordProfile", + ] + } } `, d.url, d.clientId, d.clientSecret, d.tenantId, mpDisabled, d.rd, d.orgDomain) } @@ -194,7 +196,7 @@ resource "restful_resource" "test" { path = "/users" read_path = "$(path)/$(body.id)" merge_patch_disabled = %t - body = jsonencode({ + body = { accountEnabled = false mailNickname = "AdeleV" passwordProfile = { @@ -202,12 +204,14 @@ resource "restful_resource" "test" { } displayName = "J.Doe2" userPrincipalName = "%d@%s" - }) - write_only_attrs = [ - "mailNickname", - "accountEnabled", - "passwordProfile", - ] + } + lifecycle { + ignore_changes = [ + "body.mailNickname", + "body.accountEnabled", + "body.passwordProfile", + ] + } } `, d.url, d.clientId, d.clientSecret, d.tenantId, mpDisabled, d.rd, d.orgDomain) } From 386a0fba71e70873557eaffd75476a83929dcdc1 Mon Sep 17 00:00:00 2001 From: magodo Date: Thu, 11 Apr 2024 13:51:44 +0800 Subject: [PATCH 12/24] Restore `write_only_attrs` && avoid read to set `body` during create/update --- internal/provider/data_source_mtls_test.go | 2 +- internal/provider/resource.go | 116 ++++++++++++++++----- internal/provider/resource_msgraph_test.go | 24 ++--- 3 files changed, 103 insertions(+), 39 deletions(-) diff --git a/internal/provider/data_source_mtls_test.go b/internal/provider/data_source_mtls_test.go index 46ab198..129d993 100644 --- a/internal/provider/data_source_mtls_test.go +++ b/internal/provider/data_source_mtls_test.go @@ -42,7 +42,7 @@ func TestDataSourceMTLS(t *testing.T) { { Config: mtlsConfig(server.URL, caCert, clientCert, clientKey), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr(addr, "output", resp), + resource.TestCheckResourceAttrSet(addr, "output.%"), ), }, }, diff --git a/internal/provider/resource.go b/internal/provider/resource.go index 4fdcfba..4115807 100644 --- a/internal/provider/resource.go +++ b/internal/provider/resource.go @@ -26,6 +26,7 @@ import ( "github.com/magodo/terraform-provider-restful/internal/dynamic" myvalidator "github.com/magodo/terraform-provider-restful/internal/validator" "github.com/tidwall/gjson" + "github.com/tidwall/sjson" ) type Resource struct { @@ -65,9 +66,10 @@ type resourceData struct { RetryUpdate types.Object `tfsdk:"retry_update"` RetryDelete types.Object `tfsdk:"retry_delete"` - MergePatchDisabled types.Bool `tfsdk:"merge_patch_disabled"` - Query types.Map `tfsdk:"query"` - Header types.Map `tfsdk:"header"` + WriteOnlyAttributes types.List `tfsdk:"write_only_attrs"` + MergePatchDisabled types.Bool `tfsdk:"merge_patch_disabled"` + Query types.Map `tfsdk:"query"` + Header types.Map `tfsdk:"header"` CheckExistance types.Bool `tfsdk:"check_existance"` ForceNewAttrs types.Set `tfsdk:"force_new_attrs"` @@ -427,6 +429,12 @@ func (r *Resource) Schema(ctx context.Context, req resource.SchemaRequest, resp stringvalidator.OneOf("DELETE", "POST"), }, }, + "write_only_attrs": schema.ListAttribute{ + Description: "A list of paths (in gjson syntax) to the attributes that are only settable, but won't be read in GET response.", + MarkdownDescription: "A list of paths (in [gjson syntax](https://github.com/tidwall/gjson/blob/master/SYNTAX.md)) to the attributes that are only settable, but won't be read in GET response.", + Optional: true, + ElementType: types.StringType, + }, "merge_patch_disabled": schema.BoolAttribute{ Description: "Whether to use a JSON Merge Patch as the request body in the PATCH update? This is only effective when `update_method` is set to `PATCH`. This overrides the `merge_patch_disabled` set in the provider block (defaults to `false`).", MarkdownDescription: "Whether to use a JSON Merge Patch as the request body in the PATCH update? This is only effective when `update_method` is set to `PATCH`. This overrides the `merge_patch_disabled` set in the provider block (defaults to `false`).", @@ -470,12 +478,35 @@ func (r *Resource) Schema(ctx context.Context, req resource.SchemaRequest, resp } func (r *Resource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) { - // var config resourceData - // diags := req.Config.Get(ctx, &config) - // resp.Diagnostics.Append(diags...) - // if diags.HasError() { - // return - // } + var config resourceData + diags := req.Config.Get(ctx, &config) + resp.Diagnostics.Append(diags...) + if diags.HasError() { + return + } + if !config.Body.IsUnknown() { + b, err := dynamic.ToJSON(config.Body) + if err != nil { + resp.Diagnostics.AddError( + "Invalid configuration", + fmt.Sprintf("marshal body: %v", err), + ) + return + } + if !config.WriteOnlyAttributes.IsUnknown() && !config.WriteOnlyAttributes.IsNull() { + for _, ie := range config.WriteOnlyAttributes.Elements() { + ie := ie.(types.String) + if !ie.IsUnknown() && !ie.IsNull() { + if !gjson.Get(string(b), ie.ValueString()).Exists() { + resp.Diagnostics.AddError( + "Invalid configuration", + fmt.Sprintf(`Invalid path in "write_only_attrs": %s`, ie.String()), + ) + } + } + } + } + } } func (r *Resource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { @@ -721,7 +752,7 @@ func (r Resource) Create(ctx context.Context, req resource.CreateRequest, resp * State: resp.State, Diagnostics: resp.Diagnostics, } - r.Read(ctx, rreq, &rresp) + r.read(ctx, rreq, &rresp, false) *resp = resource.CreateResponse{ State: rresp.State, @@ -730,6 +761,10 @@ func (r Resource) Create(ctx context.Context, req resource.CreateRequest, resp * } func (r Resource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + r.read(ctx, req, resp, true) +} + +func (r Resource) read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse, updateBody bool) { var state resourceData diags := req.State.Get(ctx, &state) resp.Diagnostics.Append(diags...) @@ -773,22 +808,55 @@ func (r Resource) Read(ctx context.Context, req resource.ReadRequest, resp *reso b = []byte(bodyLocator.LocateValueInResp(*response)) } - var body types.Dynamic - if body, err = dynamic.FromJSON(b, state.Body.UnderlyingValue().Type(ctx)); err != nil { - // An error might occur here during refresh, when the type of the state doesn't match the remote, - // e.g. a tuple field has different number of elements. - // In this case, we fallback to the implied types, to make the refresh proceed and return a reasonable plan diff. - if body, err = dynamic.FromJSONImplied(b); err != nil { - resp.Diagnostics.AddError( - "Evaluating `body` during Read", - err.Error(), - ) + if updateBody { + var writeOnlyAttributes []string + diags = state.WriteOnlyAttributes.ElementsAs(ctx, &writeOnlyAttributes, false) + resp.Diagnostics.Append(diags...) + if diags.HasError() { return } - } - // Set body, which is modified during read. - state.Body = body + // Update the read response by compensating the write only attributes from state + if len(writeOnlyAttributes) != 0 { + stateBody, err := dynamic.ToJSON(state.Body) + if err != nil { + resp.Diagnostics.AddError( + "Read failure", + fmt.Sprintf("marshal state body: %v", err), + ) + return + } + pb := string(b) + for _, path := range writeOnlyAttributes { + if gjson.Get(string(stateBody), path).Exists() && !gjson.Get(string(b), path).Exists() { + pb, err = sjson.Set(pb, path, gjson.Get(string(stateBody), path).Value()) + if err != nil { + resp.Diagnostics.AddError( + "Read failure", + fmt.Sprintf("json set write only attr at path %q: %v", path, err), + ) + return + } + } + } + b = []byte(pb) + } + + var body types.Dynamic + if body, err = dynamic.FromJSON(b, state.Body.UnderlyingValue().Type(ctx)); err != nil { + // An error might occur here during refresh, when the type of the state doesn't match the remote, + // e.g. a tuple field has different number of elements. + // In this case, we fallback to the implied types, to make the refresh proceed and return a reasonable plan diff. + if body, err = dynamic.FromJSONImplied(b); err != nil { + resp.Diagnostics.AddError( + "Evaluating `body` during Read", + err.Error(), + ) + return + } + } + state.Body = body + } // Set output if !state.OutputAttrs.IsNull() { @@ -976,7 +1044,7 @@ func (r Resource) Update(ctx context.Context, req resource.UpdateRequest, resp * State: resp.State, Diagnostics: resp.Diagnostics, } - r.Read(ctx, rreq, &rresp) + r.read(ctx, rreq, &rresp, false) *resp = resource.UpdateResponse{ State: rresp.State, diff --git a/internal/provider/resource_msgraph_test.go b/internal/provider/resource_msgraph_test.go index 980a846..18313f7 100644 --- a/internal/provider/resource_msgraph_test.go +++ b/internal/provider/resource_msgraph_test.go @@ -164,13 +164,11 @@ resource "restful_resource" "test" { displayName = "J.Doe" userPrincipalName = "%d@%s" } - lifecycle { - ignore_changes = [ - "body.mailNickname", - "body.accountEnabled", - "body.passwordProfile", - ] - } + write_only_attrs = [ + "mailNickname", + "accountEnabled", + "passwordProfile", + ] } `, d.url, d.clientId, d.clientSecret, d.tenantId, mpDisabled, d.rd, d.orgDomain) } @@ -205,13 +203,11 @@ resource "restful_resource" "test" { displayName = "J.Doe2" userPrincipalName = "%d@%s" } - lifecycle { - ignore_changes = [ - "body.mailNickname", - "body.accountEnabled", - "body.passwordProfile", - ] - } + write_only_attrs = [ + "mailNickname", + "accountEnabled", + "passwordProfile", + ] } `, d.url, d.clientId, d.clientSecret, d.tenantId, mpDisabled, d.rd, d.orgDomain) } From 79a61287b09fa9f651c241bd4816daa455ecdc02 Mon Sep 17 00:00:00 2001 From: magodo Date: Thu, 11 Apr 2024 18:38:51 +0800 Subject: [PATCH 13/24] Update examples --- .../resources/restful_resource/resource.tf | 4 +-- examples/usecases/azure/route_table/main.tf | 16 +++++----- .../usecases/azure/virtual_network/main.tf | 8 ++--- examples/usecases/feedly/main.tf | 8 ++--- examples/usecases/msgraph/main.tf | 8 ++--- examples/usecases/spotify/main.tf | 12 +++---- examples/usecases/thingsboard/main.tf | 32 +++++++++---------- 7 files changed, 44 insertions(+), 44 deletions(-) diff --git a/examples/resources/restful_resource/resource.tf b/examples/resources/restful_resource/resource.tf index b20e730..ba27104 100644 --- a/examples/resources/restful_resource/resource.tf +++ b/examples/resources/restful_resource/resource.tf @@ -11,10 +11,10 @@ resource "restful_resource" "rg" { pending = ["202", "200"] } } - body = jsonencode({ + body = { location = "westus" tags = { foo = "bar" } - }) + } } diff --git a/examples/usecases/azure/route_table/main.tf b/examples/usecases/azure/route_table/main.tf index 7f1fee9..5dee4af 100644 --- a/examples/usecases/azure/route_table/main.tf +++ b/examples/usecases/azure/route_table/main.tf @@ -49,12 +49,12 @@ resource "restful_resource" "rg" { pending = ["202", "200"] } } - body = jsonencode({ + body = { location = "westus" tags = { foo = "bar" } - }) + } } locals { @@ -80,9 +80,9 @@ resource "restful_resource" "table" { query = { api-version = ["2022-07-01"] } - body = jsonencode({ + body = { location = "westus" - }) + } poll_create = local.poll poll_delete = local.poll } @@ -101,12 +101,12 @@ resource "restful_resource" "route1" { poll_update = local.poll poll_delete = local.poll - body = jsonencode({ + body = { properties = { nextHopType = "VnetLocal" addressPrefix = "10.1.0.0/16" } - }) + } } resource "restful_resource" "route2" { @@ -123,10 +123,10 @@ resource "restful_resource" "route2" { poll_update = local.poll poll_delete = local.poll - body = jsonencode({ + body = { properties = { nextHopType = "VnetLocal" addressPrefix = "10.2.0.0/16" } - }) + } } diff --git a/examples/usecases/azure/virtual_network/main.tf b/examples/usecases/azure/virtual_network/main.tf index f831458..d058a4e 100644 --- a/examples/usecases/azure/virtual_network/main.tf +++ b/examples/usecases/azure/virtual_network/main.tf @@ -49,12 +49,12 @@ resource "restful_resource" "rg" { pending = ["202", "200"] } } - body = jsonencode({ + body = { location = "westus" tags = { foo = "bar" } - }) + } } locals { @@ -77,7 +77,7 @@ resource "restful_resource" "vnet" { poll_create = local.vnet_poll poll_update = local.vnet_poll poll_delete = local.vnet_poll - body = jsonencode({ + body = { location = "westus" properties = { addressSpace = { @@ -92,5 +92,5 @@ resource "restful_resource" "vnet" { } ] } - }) + } } diff --git a/examples/usecases/feedly/main.tf b/examples/usecases/feedly/main.tf index 41371c0..0916436 100644 --- a/examples/usecases/feedly/main.tf +++ b/examples/usecases/feedly/main.tf @@ -35,9 +35,9 @@ resource "restful_resource" "collection_go" { update_method = "POST" read_path = "$(path)/$(body.0.id)" read_selector = "0" - body = jsonencode({ + body = { label = "Go" - }) + } } resource "restful_resource" "feeds" { @@ -47,7 +47,7 @@ resource "restful_resource" "feeds" { create_selector = "#[feedId == \"${each.value}\"]" read_path = "feeds/$(body.id)" delete_path = "${restful_resource.collection_go.id}/feeds/$(body.id)" - body = jsonencode({ + body = { id = each.value - }) + } } diff --git a/examples/usecases/msgraph/main.tf b/examples/usecases/msgraph/main.tf index 65fa7b6..ea12497 100644 --- a/examples/usecases/msgraph/main.tf +++ b/examples/usecases/msgraph/main.tf @@ -36,7 +36,7 @@ provider "restful" { resource "restful_resource" "group" { path = "/groups" read_path = "$(path)/$(body.id)" - body = jsonencode({ + body = { description = "Self help community for library" displayName = "Library Assist" groupTypes = [ @@ -45,13 +45,13 @@ resource "restful_resource" "group" { mailEnabled = true mailNickname = "library" securityEnabled = false - }) + } } resource "restful_resource" "user" { path = "/users" read_path = "$(path)/$(body.id)" - body = jsonencode({ + body = { accountEnabled = true mailNickname = "AdeleV" displayName = "J.Doe" @@ -59,7 +59,7 @@ resource "restful_resource" "user" { passwordProfile = { password = "SecretP@sswd99!" } - }) + } write_only_attrs = [ "mailNickname", "accountEnabled", diff --git a/examples/usecases/spotify/main.tf b/examples/usecases/spotify/main.tf index 88f7fb3..56df659 100644 --- a/examples/usecases/spotify/main.tf +++ b/examples/usecases/spotify/main.tf @@ -26,12 +26,12 @@ data "restful_resource" "me" { } resource "restful_resource" "playlist" { - path = "/users/${jsondecode(data.restful_resource.me.output).id}/playlists" + path = "/users/${data.restful_resource.me.output.id}/playlists" read_path = "/playlists/$(body.id)" delete_path = "/playlists/$(body.id)/followers" - body = jsonencode({ + body = { name = "World Cup (by Terraform)" - }) + } } locals { @@ -55,7 +55,7 @@ data "restful_resource" "track" { resource "restful_operation" "add_tracks_to_playlist" { path = "${restful_resource.playlist.id}/tracks" method = "PUT" - body = jsonencode({ - uris = [for d in data.restful_resource.track : jsondecode(d.output).tracks.items[0].uri] - }) + body = { + uris = [for d in data.restful_resource.track : d.output.tracks.items[0].uri] + } } diff --git a/examples/usecases/thingsboard/main.tf b/examples/usecases/thingsboard/main.tf index 49454af..18bba31 100644 --- a/examples/usecases/thingsboard/main.tf +++ b/examples/usecases/thingsboard/main.tf @@ -50,10 +50,10 @@ data "restful_resource" "user" { resource "restful_resource" "customer" { path = "/customer" read_path = "$(path)/$(body.id.id)" - body = jsonencode({ + body = { title = "Example Company" tenantId = { - id = jsondecode(data.restful_resource.user.output).tenantId.id + id = data.restful_resource.user.output.tenantId.id entityType = "TENANT" } country = "US" @@ -64,15 +64,15 @@ resource "restful_resource" "customer" { zip = "10004" phone = "+1(415)777-7777" email = "example@company.com" - }) + } } resource "restful_resource" "device_profile" { path = "/deviceProfile" read_path = "$(path)/$(body.id.id)" - body = jsonencode({ + body = { tenantId = { - id = jsondecode(data.restful_resource.user.output).tenantId.id + id = data.restful_resource.user.output.tenantId.id entityType = "TENANT" } name = "My Profile" @@ -99,28 +99,28 @@ resource "restful_resource" "device_profile" { firmwareId = null softwareId = null default = false - }) + } } resource "restful_resource" "device" { path = "/device" read_path = "$(path)/$(body.id.id)" - body = jsonencode({ + body = { tenantId = { - id = jsondecode(data.restful_resource.user.output).tenantId.id + id = data.restful_resource.user.output.tenantId.id entityType = "TENANT" } customerId = { - id = jsondecode(restful_resource.customer.output).id.id + id = restful_resource.customer.output.id.id entityType = "CUSTOMER" } name = "My Device" label = "Room 123 Sensor" deviceProfileId : { - id = jsondecode(restful_resource.device_profile.output).id.id + id = restful_resource.device_profile.output.id.id entityType = "DEVICE_PROFILE" } - }) + } } data "restful_resource" "device_credential" { @@ -137,7 +137,7 @@ locals { resolveMultiple = false singleEntity = { entityType = "DEVICE" - id = jsondecode(restful_resource.device.output).id.id + id = restful_resource.device.output.id.id } type = "singleEntity" } @@ -208,9 +208,9 @@ locals { resource "restful_resource" "dashboard" { path = "/dashboard" read_path = "$(path)/$(body.id.id)" - body = jsonencode({ + body = { tenantId = { - id = jsondecode(data.restful_resource.user.output).tenantId.id + id = data.restful_resource.user.output.tenantId.id entityType = "TENANT" } title = "My Dashboard" @@ -256,10 +256,10 @@ resource "restful_resource" "dashboard" { (local.my_device_widget.id) = local.my_device_widget } } - }) + } } output "device_token" { - value = jsondecode(data.restful_resource.device_credential.output).credentialsId + value = data.restful_resource.device_credential.output.credentialsId sensitive = true } From 8b168a3e0df43571f622a2a5db2b2b3783cc224a Mon Sep 17 00:00:00 2001 From: magodo Date: Thu, 11 Apr 2024 18:43:20 +0800 Subject: [PATCH 14/24] generate doc --- docs/data-sources/resource.md | 4 +--- docs/resources/operation.md | 8 +++----- docs/resources/resource.md | 8 ++++---- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/docs/data-sources/resource.md b/docs/data-sources/resource.md index ae5f0d0..366175c 100644 --- a/docs/data-sources/resource.md +++ b/docs/data-sources/resource.md @@ -38,7 +38,7 @@ data "restful_resource" "test" { ### Read-Only -- `output` (String) The response body after reading the resource. +- `output` (Dynamic) The response body after reading the resource. ### Nested Schema for `precheck` @@ -101,5 +101,3 @@ Required: Optional: - `pending` (List of String) The expected status sentinels for pending status. - - diff --git a/docs/resources/operation.md b/docs/resources/operation.md index e391955..68135a1 100644 --- a/docs/resources/operation.md +++ b/docs/resources/operation.md @@ -40,8 +40,8 @@ resource "restful_operation" "register_rp" { ### Optional -- `body` (String) The payload for the `Create`/`Update` call. -- `delete_body` (String) The payload for the `Delete` call. +- `body` (Dynamic) The payload for the `Create`/`Update` call. +- `delete_body` (Dynamic) The payload for the `Delete` call. - `delete_method` (String) The method for the `Delete` call. Possible values are `POST`, `PUT`, `PATCH` and `DELETE`. If this is not specified, no `Delete` call will occur. - `delete_path` (String) The path for the `Delete` call, relative to the `base_url` of the provider. The `path` is used instead if `delete_path` is absent. - `header` (Map of String) The header parameters that are applied to each request. This overrides the `header` set in the provider block. @@ -57,7 +57,7 @@ resource "restful_operation" "register_rp" { ### Read-Only - `id` (String) The ID of the operation. Same as the `path`. -- `output` (String) The response body. +- `output` (Dynamic) The response body. ### Nested Schema for `poll` @@ -238,5 +238,3 @@ Required: Optional: - `pending` (List of String) The expected status sentinels for pending status. - - diff --git a/docs/resources/resource.md b/docs/resources/resource.md index 8a7a4ca..5e93563 100644 --- a/docs/resources/resource.md +++ b/docs/resources/resource.md @@ -26,12 +26,12 @@ resource "restful_resource" "rg" { pending = ["202", "200"] } } - body = jsonencode({ + body = { location = "westus" tags = { foo = "bar" } - }) + } } ``` @@ -40,7 +40,7 @@ resource "restful_resource" "rg" { ### Required -- `body` (String) The properties of the resource. +- `body` (Dynamic) The properties of the resource. - `path` (String) The path used to create the resource, relative to the `base_url` of the provider. ### Optional @@ -74,7 +74,7 @@ resource "restful_resource" "rg" { ### Read-Only - `id` (String) The ID of the Resource. -- `output` (String) The response body after reading the resource. +- `output` (Dynamic) The response body after reading the resource. ### Nested Schema for `poll_create` From 243dd2cc19396c71e0fba6dc9a7a96d1d593f461 Mon Sep 17 00:00:00 2001 From: magodo Date: Tue, 16 Apr 2024 14:47:50 +0800 Subject: [PATCH 15/24] ModifyPlan check known-ness of `body` recursively --- internal/dynamic/dynamic.go | 53 +++++++++++++++++++++++++++++++++++ internal/provider/resource.go | 2 +- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/internal/dynamic/dynamic.go b/internal/dynamic/dynamic.go index 9a763a7..b3835ce 100644 --- a/internal/dynamic/dynamic.go +++ b/internal/dynamic/dynamic.go @@ -352,3 +352,56 @@ func attrValueFromJSONImplied(b []byte) (attr.Type, attr.Value, error) { return nil, nil, fmt.Errorf("Unhandled type: %T", v) } } + +// IsFullyKnown returns true if `val` is known. If `val` is an aggregate type, +// IsFullyKnown only returns true if all elements and attributes are known, as +// well. +func IsFullyKnown(val attr.Value) bool { + if val == nil { + return true + } + if val.IsUnknown() { + return false + } + switch v := val.(type) { + case types.Dynamic: + return IsFullyKnown(v.UnderlyingValue()) + case types.List: + for _, e := range v.Elements() { + if !IsFullyKnown(e) { + return false + } + } + return true + case types.Set: + for _, e := range v.Elements() { + if !IsFullyKnown(e) { + return false + } + } + return true + case types.Tuple: + for _, e := range v.Elements() { + if !IsFullyKnown(e) { + return false + } + } + return true + case types.Map: + for _, e := range v.Elements() { + if !IsFullyKnown(e) { + return false + } + } + return true + case types.Object: + for _, e := range v.Attributes() { + if !IsFullyKnown(e) { + return false + } + } + return true + default: + return true + } +} diff --git a/internal/provider/resource.go b/internal/provider/resource.go index 4115807..c9bdf44 100644 --- a/internal/provider/resource.go +++ b/internal/provider/resource.go @@ -534,7 +534,7 @@ func (r *Resource) ModifyPlan(ctx context.Context, req resource.ModifyPlanReques resp.Plan.Set(ctx, plan) }() - if !plan.ForceNewAttrs.IsUnknown() && !plan.Body.IsUnknown() { + if !plan.ForceNewAttrs.IsUnknown() && dynamic.IsFullyKnown(plan.Body) { var forceNewAttrs []types.String if diags := plan.ForceNewAttrs.ElementsAs(ctx, &forceNewAttrs, false); diags != nil { resp.Diagnostics.Append(diags...) From cff895825b74865300aa35a5531495627e8eb9fa Mon Sep 17 00:00:00 2001 From: magodo Date: Tue, 16 Apr 2024 17:10:17 +0800 Subject: [PATCH 16/24] clean --- internal/provider/resource.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/provider/resource.go b/internal/provider/resource.go index c9bdf44..bbc5927 100644 --- a/internal/provider/resource.go +++ b/internal/provider/resource.go @@ -463,7 +463,8 @@ func (r *Resource) Schema(ctx context.Context, req resource.SchemaRequest, resp Optional: true, ElementType: types.StringType, }, - "output_attrs": schema.SetAttribute{Description: "A set of `output` attribute paths (in gjson syntax) that will be exported in the `output`. If this is not specified, all attributes will be exported by `output`.", + "output_attrs": schema.SetAttribute{ + Description: "A set of `output` attribute paths (in gjson syntax) that will be exported in the `output`. If this is not specified, all attributes will be exported by `output`.", MarkdownDescription: "A set of `output` attribute paths (in [gjson syntax](https://github.com/tidwall/gjson/blob/master/SYNTAX.md)) that will be exported in the `output`. If this is not specified, all attributes will be exported by `output`.", Optional: true, ElementType: types.StringType, From 605af1f33b97e43a0f40159874c89b419a1918bd Mon Sep 17 00:00:00 2001 From: magodo Date: Sun, 28 Apr 2024 12:06:39 +0800 Subject: [PATCH 17/24] dynamic: List/Set/Tuple/Map/Object diff empty vs null --- internal/dynamic/dynamic.go | 6 +- internal/dynamic/dynamic_test.go | 120 +++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 3 deletions(-) diff --git a/internal/dynamic/dynamic.go b/internal/dynamic/dynamic.go index b3835ce..e2d2157 100644 --- a/internal/dynamic/dynamic.go +++ b/internal/dynamic/dynamic.go @@ -18,7 +18,7 @@ func ToJSON(d types.Dynamic) ([]byte, error) { } func attrListToJSON(in []attr.Value) ([]json.RawMessage, error) { - var l []json.RawMessage + l := []json.RawMessage{} for _, v := range in { vv, err := attrValueToJSON(v) if err != nil { @@ -105,7 +105,7 @@ func attrListFromJSON(b []byte, etyp attr.Type) ([]attr.Value, error) { if err := json.Unmarshal(b, &l); err != nil { return nil, err } - var vals []attr.Value + vals := []attr.Value{} for _, b := range l { val, err := attrValueFromJSON(b, etyp) if err != nil { @@ -202,7 +202,7 @@ func attrValueFromJSON(b []byte, typ attr.Type) (attr.Value, error) { if len(l) != len(typ.ElemTypes) { return nil, fmt.Errorf("tuple element size not match: json=%d, type=%d", len(l), len(typ.ElemTypes)) } - var vals []attr.Value + vals := []attr.Value{} for i, b := range l { val, err := attrValueFromJSON(b, typ.ElemTypes[i]) if err != nil { diff --git a/internal/dynamic/dynamic_test.go b/internal/dynamic/dynamic_test.go index 90ab2ab..82e640c 100644 --- a/internal/dynamic/dynamic_test.go +++ b/internal/dynamic/dynamic_test.go @@ -27,12 +27,18 @@ func TestToJSON(t *testing.T) { "list": types.ListType{ ElemType: types.BoolType, }, + "list_empty": types.ListType{ + ElemType: types.BoolType, + }, "list_null": types.ListType{ ElemType: types.BoolType, }, "set": types.SetType{ ElemType: types.BoolType, }, + "set_empty": types.SetType{ + ElemType: types.BoolType, + }, "set_null": types.SetType{ ElemType: types.BoolType, }, @@ -42,6 +48,9 @@ func TestToJSON(t *testing.T) { types.StringType, }, }, + "tuple_empty": types.TupleType{ + ElemTypes: []attr.Type{}, + }, "tuple_null": types.TupleType{ ElemTypes: []attr.Type{ types.BoolType, @@ -51,6 +60,9 @@ func TestToJSON(t *testing.T) { "map": types.MapType{ ElemType: types.BoolType, }, + "map_empty": types.MapType{ + ElemType: types.BoolType, + }, "map_null": types.MapType{ ElemType: types.BoolType, }, @@ -60,6 +72,9 @@ func TestToJSON(t *testing.T) { "string": types.StringType, }, }, + "object_empty": types.ObjectType{ + AttrTypes: map[string]attr.Type{}, + }, "object_null": types.ObjectType{ AttrTypes: map[string]attr.Type{ "bool": types.BoolType, @@ -85,6 +100,10 @@ func TestToJSON(t *testing.T) { types.BoolValue(false), }, ), + "list_empty": types.ListValueMust( + types.BoolType, + []attr.Value{}, + ), "list_null": types.ListNull(types.BoolType), "set": types.SetValueMust( types.BoolType, @@ -93,6 +112,10 @@ func TestToJSON(t *testing.T) { types.BoolValue(false), }, ), + "set_empty": types.SetValueMust( + types.BoolType, + []attr.Value{}, + ), "set_null": types.SetNull(types.BoolType), "tuple": types.TupleValueMust( []attr.Type{ @@ -104,6 +127,10 @@ func TestToJSON(t *testing.T) { types.StringValue("a"), }, ), + "tuple_empty": types.TupleValueMust( + []attr.Type{}, + []attr.Value{}, + ), "tuple_null": types.TupleNull( []attr.Type{ types.BoolType, @@ -116,6 +143,10 @@ func TestToJSON(t *testing.T) { "a": types.BoolValue(true), }, ), + "map_empty": types.MapValueMust( + types.BoolType, + map[string]attr.Value{}, + ), "map_null": types.MapNull(types.BoolType), "object": types.ObjectValueMust( map[string]attr.Type{ @@ -127,6 +158,10 @@ func TestToJSON(t *testing.T) { "string": types.StringValue("a"), }, ), + "object_empty": types.ObjectValueMust( + map[string]attr.Type{}, + map[string]attr.Value{}, + ), "object_null": types.ObjectNull( map[string]attr.Type{ "bool": types.BoolType, @@ -150,19 +185,24 @@ func TestToJSON(t *testing.T) { "number": 1.23, "number_null": null, "list": [true, false], + "list_empty": [], "list_null": null, "set": [true, false], + "set_empty": [], "set_null": null, "tuple": [true, "a"], + "tuple_empty": [], "tuple_null": null, "map": { "a": true }, + "map_empty": {}, "map_null": null, "object": { "bool": true, "string": "a" }, + "object_empty": {}, "object_null": null }` @@ -192,19 +232,24 @@ func TestFromJSON(t *testing.T) { "number": 1.23, "number_null": null, "list": [true, false], + "list_empty": [], "list_null": null, "set": [true, false], + "set_empty": [], "set_null": null, "tuple": [true, "a"], + "tuple_empty": [], "tuple_null": null, "map": { "a": true }, + "map_empty": {}, "map_null": null, "object": { "bool": true, "string": "a" }, + "object_empty": {}, "object_null": null, "dynamic": { "foo": "bar" @@ -227,12 +272,18 @@ func TestFromJSON(t *testing.T) { "list": types.ListType{ ElemType: types.BoolType, }, + "list_empty": types.ListType{ + ElemType: types.BoolType, + }, "list_null": types.ListType{ ElemType: types.BoolType, }, "set": types.SetType{ ElemType: types.BoolType, }, + "set_empty": types.SetType{ + ElemType: types.BoolType, + }, "set_null": types.SetType{ ElemType: types.BoolType, }, @@ -242,6 +293,9 @@ func TestFromJSON(t *testing.T) { types.StringType, }, }, + "tuple_empty": types.TupleType{ + ElemTypes: []attr.Type{}, + }, "tuple_null": types.TupleType{ ElemTypes: []attr.Type{ types.BoolType, @@ -251,6 +305,9 @@ func TestFromJSON(t *testing.T) { "map": types.MapType{ ElemType: types.BoolType, }, + "map_empty": types.MapType{ + ElemType: types.BoolType, + }, "map_null": types.MapType{ ElemType: types.BoolType, }, @@ -260,6 +317,9 @@ func TestFromJSON(t *testing.T) { "string": types.StringType, }, }, + "object_empty": types.ObjectType{ + AttrTypes: map[string]attr.Type{}, + }, "object_null": types.ObjectType{ AttrTypes: map[string]attr.Type{ "bool": types.BoolType, @@ -287,6 +347,10 @@ func TestFromJSON(t *testing.T) { types.BoolValue(false), }, ), + "list_empty": types.ListValueMust( + types.BoolType, + []attr.Value{}, + ), "list_null": types.ListNull(types.BoolType), "set": types.SetValueMust( types.BoolType, @@ -295,6 +359,10 @@ func TestFromJSON(t *testing.T) { types.BoolValue(false), }, ), + "set_empty": types.SetValueMust( + types.BoolType, + []attr.Value{}, + ), "set_null": types.SetNull(types.BoolType), "tuple": types.TupleValueMust( []attr.Type{ @@ -306,6 +374,10 @@ func TestFromJSON(t *testing.T) { types.StringValue("a"), }, ), + "tuple_empty": types.TupleValueMust( + []attr.Type{}, + []attr.Value{}, + ), "tuple_null": types.TupleNull( []attr.Type{ types.BoolType, @@ -318,6 +390,10 @@ func TestFromJSON(t *testing.T) { "a": types.BoolValue(true), }, ), + "map_empty": types.MapValueMust( + types.BoolType, + map[string]attr.Value{}, + ), "map_null": types.MapNull(types.BoolType), "object": types.ObjectValueMust( map[string]attr.Type{ @@ -329,6 +405,10 @@ func TestFromJSON(t *testing.T) { "string": types.StringValue("a"), }, ), + "object_empty": types.ObjectValueMust( + map[string]attr.Type{}, + map[string]attr.Value{}, + ), "object_null": types.ObjectNull( map[string]attr.Type{ "bool": types.BoolType, @@ -453,19 +533,24 @@ func TestFromJSONImplied(t *testing.T) { "number": 1.23, "number_null": null, "list": [true, false], + "list_empty": [], "list_null": null, "set": [true, false], + "set_empty": [], "set_null": null, "tuple": [true, "a"], + "tuple_empty": [], "tuple_null": null, "map": { "a": true }, + "map_empty": {}, "map_null": null, "object": { "bool": true, "string": "a" }, + "object_empty": {}, "object_null": null }`, expect: types.DynamicValue( @@ -487,6 +572,9 @@ func TestFromJSONImplied(t *testing.T) { types.BoolType, }, }, + "list_empty": types.TupleType{ + ElemTypes: []attr.Type{}, + }, "list_null": types.DynamicType, "set": types.TupleType{ ElemTypes: []attr.Type{ @@ -494,6 +582,9 @@ func TestFromJSONImplied(t *testing.T) { types.BoolType, }, }, + "set_empty": types.TupleType{ + ElemTypes: []attr.Type{}, + }, "set_null": types.DynamicType, "tuple": types.TupleType{ ElemTypes: []attr.Type{ @@ -501,12 +592,18 @@ func TestFromJSONImplied(t *testing.T) { types.StringType, }, }, + "tuple_empty": types.TupleType{ + ElemTypes: []attr.Type{}, + }, "tuple_null": types.DynamicType, "map": types.ObjectType{ AttrTypes: map[string]attr.Type{ "a": types.BoolType, }, }, + "map_empty": types.ObjectType{ + AttrTypes: map[string]attr.Type{}, + }, "map_null": types.DynamicType, "object": types.ObjectType{ AttrTypes: map[string]attr.Type{ @@ -514,6 +611,9 @@ func TestFromJSONImplied(t *testing.T) { "string": types.StringType, }, }, + "object_empty": types.ObjectType{ + AttrTypes: map[string]attr.Type{}, + }, "object_null": types.DynamicType, }, map[string]attr.Value{ @@ -537,6 +637,10 @@ func TestFromJSONImplied(t *testing.T) { types.BoolValue(false), }, ), + "list_empty": types.TupleValueMust( + []attr.Type{}, + []attr.Value{}, + ), "list_null": types.DynamicNull(), "set": types.TupleValueMust( []attr.Type{ @@ -548,6 +652,10 @@ func TestFromJSONImplied(t *testing.T) { types.BoolValue(false), }, ), + "set_empty": types.TupleValueMust( + []attr.Type{}, + []attr.Value{}, + ), "set_null": types.DynamicNull(), "tuple": types.TupleValueMust( []attr.Type{ @@ -559,6 +667,10 @@ func TestFromJSONImplied(t *testing.T) { types.StringValue("a"), }, ), + "tuple_empty": types.TupleValueMust( + []attr.Type{}, + []attr.Value{}, + ), "tuple_null": types.DynamicNull(), "map": types.ObjectValueMust( map[string]attr.Type{ @@ -568,6 +680,10 @@ func TestFromJSONImplied(t *testing.T) { "a": types.BoolValue(true), }, ), + "map_empty": types.ObjectValueMust( + map[string]attr.Type{}, + map[string]attr.Value{}, + ), "map_null": types.DynamicNull(), "object": types.ObjectValueMust( map[string]attr.Type{ @@ -579,6 +695,10 @@ func TestFromJSONImplied(t *testing.T) { "string": types.StringValue("a"), }, ), + "object_empty": types.ObjectValueMust( + map[string]attr.Type{}, + map[string]attr.Value{}, + ), "object_null": types.DynamicNull(), }, ), From 9a91d492de1f059010f9afd399b039c30d9b3d6e Mon Sep 17 00:00:00 2001 From: magodo Date: Sat, 11 May 2024 16:35:53 +0800 Subject: [PATCH 18/24] Adding test to cover `body` is a JSON array --- .../resource_dead_simple_json_server_test.go | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 internal/provider/resource_dead_simple_json_server_test.go diff --git a/internal/provider/resource_dead_simple_json_server_test.go b/internal/provider/resource_dead_simple_json_server_test.go new file mode 100644 index 0000000..98c4b80 --- /dev/null +++ b/internal/provider/resource_dead_simple_json_server_test.go @@ -0,0 +1,116 @@ +package provider_test + +import ( + "context" + "fmt" + "net/http" + "os" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "github.com/magodo/terraform-provider-restful/internal/acceptance" + "github.com/magodo/terraform-provider-restful/internal/client" +) + +const RESTFUL_DEAD_SIMPLE_SERVER_URL = "RESTFUL_DEAD_SIMPLE_SERVER_URL" + +type deadSimpleServerData struct { + url string +} + +func (d deadSimpleServerData) precheck(t *testing.T) { + if d.url == "" { + t.Skipf("%q is not specified", RESTFUL_DEAD_SIMPLE_SERVER_URL) + } + return +} + +func newDeadSimpleServerData() deadSimpleServerData { + return deadSimpleServerData{ + url: os.Getenv(RESTFUL_DEAD_SIMPLE_SERVER_URL), + } +} + +func TestResource_DeadSimpleServer_Basic(t *testing.T) { + addr := "restful_resource.test" + d := newDeadSimpleServerData() + resource.Test(t, resource.TestCase{ + PreCheck: func() { d.precheck(t) }, + CheckDestroy: d.CheckDestroy(addr), + ProtoV6ProviderFactories: acceptance.ProviderFactory(), + Steps: []resource.TestStep{ + { + Config: d.basic("foo"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet(addr, "output.#"), + ), + }, + { + ResourceName: addr, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"create_method"}, + ImportStateIdFunc: func(s *terraform.State) (string, error) { + return `{"id": "test", "path": "test", "body": [{"foo": null}]}`, nil + }, + }, + { + Config: d.basic("bar"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet(addr, "output.#"), + ), + }, + { + ResourceName: addr, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"create_method"}, + ImportStateIdFunc: func(s *terraform.State) (string, error) { + return `{"id": "test", "path": "test", "body": [{"foo": null}]}`, nil + }, + }, + }, + }) +} + +func (d deadSimpleServerData) CheckDestroy(addr string) func(*terraform.State) error { + return func(s *terraform.State) error { + c, err := client.New(context.TODO(), d.url, nil) + if err != nil { + return err + } + for key, resource := range s.RootModule().Resources { + if key != addr { + continue + } + resp, err := c.Read(context.TODO(), resource.Primary.ID, client.ReadOption{}) + if err != nil { + return fmt.Errorf("reading %s: %v", addr, err) + } + if resp.StatusCode() != http.StatusNotFound { + return fmt.Errorf("%s: still exists", addr) + } + return nil + } + panic("unreachable") + } +} + +func (d deadSimpleServerData) basic(v string) string { + return fmt.Sprintf(` +provider "restful" { + base_url = %q +} + +resource "restful_resource" "test" { + path = "test" + create_method = "PUT" + body = [ + { + foo = %q + } + ] +} +`, d.url, v) +} From f6774670744975a096a45be862b9cf71b22d0f3f Mon Sep 17 00:00:00 2001 From: magodo Date: Thu, 16 May 2024 11:17:11 +0800 Subject: [PATCH 19/24] Back port from #94 --- internal/client/async.go | 36 ++++++++++++++++++++------------ internal/client/client.go | 4 ++-- internal/provider/data_source.go | 28 ++++++++++++------------- internal/provider/resource.go | 18 ++++++++++++++-- 4 files changed, 55 insertions(+), 31 deletions(-) diff --git a/internal/client/async.go b/internal/client/async.go index da0244e..7ac1526 100644 --- a/internal/client/async.go +++ b/internal/client/async.go @@ -14,14 +14,16 @@ import ( // ValueLocator indicates where a value is located in a HTTP response. type ValueLocator interface { - LocateValueInResp(resty.Response) string + LocateValueInResp(resty.Response) (string, bool) String() string } type ExactLocator string -func (loc ExactLocator) LocateValueInResp(_ resty.Response) string { - return string(loc) +var _ ValueLocator = ExactLocator("") + +func (loc ExactLocator) LocateValueInResp(_ resty.Response) (string, bool) { + return string(loc), true } func (loc ExactLocator) String() string { return fmt.Sprintf(`exact.%s`, string(loc)) @@ -29,8 +31,11 @@ func (loc ExactLocator) String() string { type HeaderLocator string -func (loc HeaderLocator) LocateValueInResp(resp resty.Response) string { - return resp.Header().Get(string(loc)) +var _ ValueLocator = HeaderLocator("") + +func (loc HeaderLocator) LocateValueInResp(resp resty.Response) (string, bool) { + v := resp.Header().Get(string(loc)) + return v, v != "" } func (loc HeaderLocator) String() string { return fmt.Sprintf(`header.%s`, string(loc)) @@ -38,9 +43,11 @@ func (loc HeaderLocator) String() string { type BodyLocator string -func (loc BodyLocator) LocateValueInResp(resp resty.Response) string { +var _ ValueLocator = BodyLocator("") + +func (loc BodyLocator) LocateValueInResp(resp resty.Response) (string, bool) { result := gjson.GetBytes(resp.Body(), string(loc)) - return result.String() + return result.String(), result.Exists() } func (loc BodyLocator) String() string { return fmt.Sprintf(`body.%s`, string(loc)) @@ -48,8 +55,10 @@ func (loc BodyLocator) String() string { type CodeLocator struct{} -func (loc CodeLocator) LocateValueInResp(resp resty.Response) string { - return strconv.Itoa(resp.StatusCode()) +var _ ValueLocator = CodeLocator{} + +func (loc CodeLocator) LocateValueInResp(resp resty.Response) (string, bool) { + return strconv.Itoa(resp.StatusCode()), true } func (loc CodeLocator) String() string { return "code" @@ -106,8 +115,9 @@ func NewPollableForPoll(resp resty.Response, opt PollOption) (*Pollable, error) var rawURL string if loc := opt.UrlLocator; loc != nil { - rawURL = loc.LocateValueInResp(resp) - if rawURL == "" { + var ok bool + rawURL, ok = loc.LocateValueInResp(resp) + if !ok { return nil, fmt.Errorf("No polling URL found in %s", loc) } } else { @@ -188,8 +198,8 @@ PollingLoop: } } - status := f.StatusLocator.LocateValueInResp(*resp) - if status == "" { + status, ok := f.StatusLocator.LocateValueInResp(*resp) + if !ok { return fmt.Errorf("No status value found from %s", f.StatusLocator) } // We tolerate case difference here to be pragmatic. diff --git a/internal/client/client.go b/internal/client/client.go index fd3e85c..a7d7f79 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -147,8 +147,8 @@ func (c *Client) setRetry(opt RetryOption) { return true } - status := opt.StatusLocator.LocateValueInResp(*r) - if status == "" { + status, ok := opt.StatusLocator.LocateValueInResp(*r) + if !ok { return false } // We tolerate case difference here to be pragmatic. diff --git a/internal/provider/data_source.go b/internal/provider/data_source.go index 2cd5ed6..f6c4ce1 100644 --- a/internal/provider/data_source.go +++ b/internal/provider/data_source.go @@ -10,8 +10,8 @@ import ( "github.com/hashicorp/terraform-plugin-framework/datasource/schema" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/magodo/terraform-provider-restful/internal/client" "github.com/magodo/terraform-provider-restful/internal/dynamic" - "github.com/tidwall/gjson" ) type DataSource struct { @@ -171,23 +171,23 @@ func (d *DataSource) Read(ctx context.Context, req datasource.ReadRequest, resp b := response.Body() - if config.Selector.ValueString() != "" { - result := gjson.GetBytes(b, config.Selector.ValueString()) - if !result.Exists() { + if sel := config.Selector.ValueString(); sel != "" { + bodyLocator := client.BodyLocator(sel) + sb, ok := bodyLocator.LocateValueInResp(*response) + if !ok { + if config.AllowNotExist.ValueBool() { + // Setting the input attributes to the state anyway + diags = resp.State.Set(ctx, state) + resp.Diagnostics.Append(diags...) + return + } resp.Diagnostics.AddError( - fmt.Sprintf("Failed to select resource from response"), - fmt.Sprintf("Can't find resource with query %q", config.Selector.ValueString()), + fmt.Sprintf("`selector` failed to select from the response"), + string(response.Body()), ) return } - if len(result.Array()) > 1 { - resp.Diagnostics.AddError( - fmt.Sprintf("Failed to select resource from response"), - fmt.Sprintf("Multiple resources with query %q found (%d)", config.Selector.ValueString(), len(result.Array())), - ) - return - } - b = []byte(result.Array()[0].Raw) + b = []byte(sb) } // Set output diff --git a/internal/provider/resource.go b/internal/provider/resource.go index bbc5927..4263418 100644 --- a/internal/provider/resource.go +++ b/internal/provider/resource.go @@ -684,7 +684,15 @@ func (r Resource) Create(ctx context.Context, req resource.CreateRequest, resp * if sel := plan.CreateSelector.ValueString(); sel != "" { // Guaranteed by schema bodyLocator := client.BodyLocator(sel) - b = []byte(bodyLocator.LocateValueInResp(*response)) + sb, ok := bodyLocator.LocateValueInResp(*response) + if !ok { + resp.Diagnostics.AddError( + fmt.Sprintf("`create_selector` failed to select from the response"), + string(response.Body()), + ) + return + } + b = []byte(sb) } // Construct the resource id, which is used as the path to read the resource later on. By default, it is the same as the "path", unless "read_path" is specified. @@ -806,7 +814,13 @@ func (r Resource) read(ctx context.Context, req resource.ReadRequest, resp *reso if sel := state.ReadSelector.ValueString(); sel != "" { // Guaranteed by schema bodyLocator := client.BodyLocator(sel) - b = []byte(bodyLocator.LocateValueInResp(*response)) + sb, ok := bodyLocator.LocateValueInResp(*response) + // This means the tracked resource selected (filtered) from the response now disappears (deleted out of band). + if !ok { + resp.State.RemoveResource(ctx) + return + } + b = []byte(sb) } if updateBody { From 8c51fb1ba5e0ffe4b94f161c6d623ae427ddd612 Mon Sep 17 00:00:00 2001 From: magodo Date: Fri, 17 May 2024 19:58:45 +0800 Subject: [PATCH 20/24] `restful_resource` - `[read|update_delete]_path` builder pattern supports `$(body)` (#96) --- internal/buildpath/path.go | 4 + .../resource_dead_simple_json_server_test.go | 134 ++++++++++++++---- 2 files changed, 110 insertions(+), 28 deletions(-) diff --git a/internal/buildpath/path.go b/internal/buildpath/path.go index 2f35356..b3d44e1 100644 --- a/internal/buildpath/path.go +++ b/internal/buildpath/path.go @@ -37,6 +37,10 @@ func BuildPath(pattern string, baseURL, path string, body []byte) (string, error out = strings.ReplaceAll(out, match[0], path) continue } + if match[2] == "body" { + out = strings.ReplaceAll(out, match[0], string(body)) + continue + } if strings.HasPrefix(match[2], "body.") { jsonPath := strings.TrimPrefix(match[2], "body.") prop := gjson.GetBytes(body, jsonPath) diff --git a/internal/provider/resource_dead_simple_json_server_test.go b/internal/provider/resource_dead_simple_json_server_test.go index 98c4b80..773e8fd 100644 --- a/internal/provider/resource_dead_simple_json_server_test.go +++ b/internal/provider/resource_dead_simple_json_server_test.go @@ -3,8 +3,10 @@ package provider_test import ( "context" "fmt" + "io" "net/http" - "os" + "net/http/httptest" + "net/url" "testing" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" @@ -13,35 +15,42 @@ import ( "github.com/magodo/terraform-provider-restful/internal/client" ) -const RESTFUL_DEAD_SIMPLE_SERVER_URL = "RESTFUL_DEAD_SIMPLE_SERVER_URL" +type deadSimpleServerData struct{} -type deadSimpleServerData struct { - url string -} - -func (d deadSimpleServerData) precheck(t *testing.T) { - if d.url == "" { - t.Skipf("%q is not specified", RESTFUL_DEAD_SIMPLE_SERVER_URL) - } - return -} +func TestResource_DeadSimpleServer_ObjectArray(t *testing.T) { + addr := "restful_resource.test" -func newDeadSimpleServerData() deadSimpleServerData { - return deadSimpleServerData{ - url: os.Getenv(RESTFUL_DEAD_SIMPLE_SERVER_URL), + type object struct { + b []byte + id string } -} - -func TestResource_DeadSimpleServer_Basic(t *testing.T) { - addr := "restful_resource.test" - d := newDeadSimpleServerData() + var obj *object + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "PUT": + b, _ := io.ReadAll(r.Body) + r.Body.Close() + obj = &object{b: b, id: r.URL.String()} + return + case "GET": + if obj == nil || r.URL.String() != obj.id { + w.WriteHeader(http.StatusNotFound) + return + } + w.Write(obj.b) + return + case "DELETE": + obj = nil + return + } + })) + d := deadSimpleServerData{} resource.Test(t, resource.TestCase{ - PreCheck: func() { d.precheck(t) }, - CheckDestroy: d.CheckDestroy(addr), + CheckDestroy: d.CheckDestroy(srv.URL, addr), ProtoV6ProviderFactories: acceptance.ProviderFactory(), Steps: []resource.TestStep{ { - Config: d.basic("foo"), + Config: d.object_array(srv.URL, "foo"), Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttrSet(addr, "output.#"), ), @@ -56,7 +65,7 @@ func TestResource_DeadSimpleServer_Basic(t *testing.T) { }, }, { - Config: d.basic("bar"), + Config: d.object_array(srv.URL, "bar"), Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttrSet(addr, "output.#"), ), @@ -74,9 +83,63 @@ func TestResource_DeadSimpleServer_Basic(t *testing.T) { }) } -func (d deadSimpleServerData) CheckDestroy(addr string) func(*terraform.State) error { +func TestResource_DeadSimpleServer_CreateRetString(t *testing.T) { + addr := "restful_resource.test" + + type object struct { + b []byte + id string + } + const id = "foo" + var obj *object + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "PUT": + b, _ := io.ReadAll(r.Body) + r.Body.Close() + r.URL.Path, _ = url.JoinPath(r.URL.Path, id) + obj = &object{b: b, id: r.URL.String()} + w.Write([]byte(id)) + return + case "GET": + if obj == nil || r.URL.String() != obj.id { + w.WriteHeader(http.StatusNotFound) + return + } + w.Write(obj.b) + return + case "DELETE": + obj = nil + return + } + })) + d := deadSimpleServerData{} + resource.Test(t, resource.TestCase{ + CheckDestroy: d.CheckDestroy(srv.URL, addr), + ProtoV6ProviderFactories: acceptance.ProviderFactory(), + Steps: []resource.TestStep{ + { + Config: d.create_ret_string(srv.URL), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet(addr, "output"), + ), + }, + { + ResourceName: addr, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"create_method", "read_path"}, + ImportStateIdFunc: func(s *terraform.State) (string, error) { + return fmt.Sprintf(`{"path": "/test", "id": "/test/%s", "body": {}}`, id), nil + }, + }, + }, + }) +} + +func (d deadSimpleServerData) CheckDestroy(url, addr string) func(*terraform.State) error { return func(s *terraform.State) error { - c, err := client.New(context.TODO(), d.url, nil) + c, err := client.New(context.TODO(), url, nil) if err != nil { return err } @@ -97,7 +160,7 @@ func (d deadSimpleServerData) CheckDestroy(addr string) func(*terraform.State) e } } -func (d deadSimpleServerData) basic(v string) string { +func (d deadSimpleServerData) object_array(url, v string) string { return fmt.Sprintf(` provider "restful" { base_url = %q @@ -112,5 +175,20 @@ resource "restful_resource" "test" { } ] } -`, d.url, v) +`, url, v) +} + +func (d deadSimpleServerData) create_ret_string(url string) string { + return fmt.Sprintf(` +provider "restful" { + base_url = %q +} + +resource "restful_resource" "test" { + path = "/test" + create_method = "PUT" + read_path = "$(path)/$(body)" + body = "{}" +} +`, url) } From 40dd22d6689874f501dacda38f5d9d70e98d4083 Mon Sep 17 00:00:00 2001 From: magodo Date: Sat, 18 May 2024 10:35:52 +0800 Subject: [PATCH 21/24] log: Enable API client log --- internal/client/client.go | 20 ++++++++++++++ internal/client/logger.go | 27 +++++++++++++++++++ .../resource_dead_simple_json_server_test.go | 4 +-- 3 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 internal/client/logger.go diff --git a/internal/client/client.go b/internal/client/client.go index a7d7f79..f9d72bc 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -94,6 +94,8 @@ func New(ctx context.Context, baseURL string, opt *BuildOption) (*Client, error) } client := resty.NewWithClient(httpClient) + client.SetDebug(true) + if opt.Security != nil { if err := opt.Security.configureClient(ctx, client); err != nil { return nil, err @@ -109,6 +111,12 @@ func New(ctx context.Context, baseURL string, opt *BuildOption) (*Client, error) return &Client{client}, nil } +// SetLoggerContext sets the ctx to the internal resty logger, as the tflog requires the current ctx. +// This needs to be called at the start of each CRUD function. +func (c *Client) SetLoggerContext(ctx context.Context) { + c.Client.SetLogger(tflogger{ctx: ctx}) +} + type RetryOption struct { StatusLocator ValueLocator Status PollingStatus @@ -175,6 +183,8 @@ type CreateOption struct { } func (c *Client) Create(ctx context.Context, path string, body interface{}, opt CreateOption) (*resty.Response, error) { + c.SetLoggerContext(ctx) + if opt.Retry != nil { c.setRetry(*opt.Retry) defer c.resetRetry() @@ -201,6 +211,8 @@ type ReadOption struct { } func (c *Client) Read(ctx context.Context, path string, opt ReadOption) (*resty.Response, error) { + c.SetLoggerContext(ctx) + if opt.Retry != nil { c.setRetry(*opt.Retry) defer c.resetRetry() @@ -222,6 +234,8 @@ type UpdateOption struct { } func (c *Client) Update(ctx context.Context, path string, body interface{}, opt UpdateOption) (*resty.Response, error) { + c.SetLoggerContext(ctx) + if opt.Retry != nil { c.setRetry(*opt.Retry) defer c.resetRetry() @@ -252,6 +266,8 @@ type DeleteOption struct { } func (c *Client) Delete(ctx context.Context, path string, opt DeleteOption) (*resty.Response, error) { + c.SetLoggerContext(ctx) + if opt.Retry != nil { c.setRetry(*opt.Retry) defer c.resetRetry() @@ -279,6 +295,8 @@ type OperationOption struct { } func (c *Client) Operation(ctx context.Context, path string, body interface{}, opt OperationOption) (*resty.Response, error) { + c.SetLoggerContext(ctx) + if opt.Retry != nil { c.setRetry(*opt.Retry) defer c.resetRetry() @@ -315,6 +333,8 @@ type ReadOptionDS struct { } func (c *Client) ReadDS(ctx context.Context, path string, opt ReadOptionDS) (*resty.Response, error) { + c.SetLoggerContext(ctx) + if opt.Retry != nil { c.setRetry(*opt.Retry) defer c.resetRetry() diff --git a/internal/client/logger.go b/internal/client/logger.go new file mode 100644 index 0000000..8bd28a4 --- /dev/null +++ b/internal/client/logger.go @@ -0,0 +1,27 @@ +package client + +import ( + "context" + "fmt" + + "github.com/go-resty/resty/v2" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +type tflogger struct { + ctx context.Context +} + +var _ resty.Logger = tflogger{} + +func (t tflogger) Debugf(format string, v ...interface{}) { + tflog.Debug(t.ctx, fmt.Sprintf(format, v...)) +} + +func (t tflogger) Warnf(format string, v ...interface{}) { + tflog.Warn(t.ctx, fmt.Sprintf(format, v...)) +} + +func (t tflogger) Errorf(format string, v ...interface{}) { + tflog.Error(t.ctx, fmt.Sprintf(format, v...)) +} diff --git a/internal/provider/resource_dead_simple_json_server_test.go b/internal/provider/resource_dead_simple_json_server_test.go index 773e8fd..342e4dc 100644 --- a/internal/provider/resource_dead_simple_json_server_test.go +++ b/internal/provider/resource_dead_simple_json_server_test.go @@ -130,7 +130,7 @@ func TestResource_DeadSimpleServer_CreateRetString(t *testing.T) { ImportStateVerify: true, ImportStateVerifyIgnore: []string{"create_method", "read_path"}, ImportStateIdFunc: func(s *terraform.State) (string, error) { - return fmt.Sprintf(`{"path": "/test", "id": "/test/%s", "body": {}}`, id), nil + return fmt.Sprintf(`{"path": "test", "id": "test/%s", "body": {}}`, id), nil }, }, }, @@ -185,7 +185,7 @@ provider "restful" { } resource "restful_resource" "test" { - path = "/test" + path = "test" create_method = "PUT" read_path = "$(path)/$(body)" body = "{}" From 9bee0bbf4cf772dcbf708d8c295eac00a8a4fabf Mon Sep 17 00:00:00 2001 From: magodo Date: Sat, 18 May 2024 10:48:04 +0800 Subject: [PATCH 22/24] Update test --- internal/provider/resource_dead_simple_json_server_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/provider/resource_dead_simple_json_server_test.go b/internal/provider/resource_dead_simple_json_server_test.go index 342e4dc..b9a10e5 100644 --- a/internal/provider/resource_dead_simple_json_server_test.go +++ b/internal/provider/resource_dead_simple_json_server_test.go @@ -121,7 +121,7 @@ func TestResource_DeadSimpleServer_CreateRetString(t *testing.T) { { Config: d.create_ret_string(srv.URL), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttrSet(addr, "output"), + resource.TestCheckNoResourceAttr(addr, "output.#"), ), }, { @@ -188,7 +188,7 @@ resource "restful_resource" "test" { path = "test" create_method = "PUT" read_path = "$(path)/$(body)" - body = "{}" + body = {} } `, url) } From a3e209834b6975014fc5f2d49701a9f1892fd60a Mon Sep 17 00:00:00 2001 From: magodo Date: Sun, 19 May 2024 09:52:49 +0800 Subject: [PATCH 23/24] Path builder pattern `$(body)` expects to be a JSON string --- internal/buildpath/path.go | 7 ++++++- internal/provider/resource_dead_simple_json_server_test.go | 4 +++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/internal/buildpath/path.go b/internal/buildpath/path.go index b3d44e1..5ca8ad2 100644 --- a/internal/buildpath/path.go +++ b/internal/buildpath/path.go @@ -1,6 +1,7 @@ package buildpath import ( + "encoding/json" "fmt" "net/url" "regexp" @@ -38,7 +39,11 @@ func BuildPath(pattern string, baseURL, path string, body []byte) (string, error continue } if match[2] == "body" { - out = strings.ReplaceAll(out, match[0], string(body)) + var str string + if err := json.Unmarshal(body, &str); err != nil { + return "", fmt.Errorf(`"body" expects type of string, but failed to unmarshal as a string: %v`, err) + } + out = strings.ReplaceAll(out, match[0], str) continue } if strings.HasPrefix(match[2], "body.") { diff --git a/internal/provider/resource_dead_simple_json_server_test.go b/internal/provider/resource_dead_simple_json_server_test.go index b9a10e5..0e1f3db 100644 --- a/internal/provider/resource_dead_simple_json_server_test.go +++ b/internal/provider/resource_dead_simple_json_server_test.go @@ -2,6 +2,7 @@ package provider_test import ( "context" + "encoding/json" "fmt" "io" "net/http" @@ -99,7 +100,8 @@ func TestResource_DeadSimpleServer_CreateRetString(t *testing.T) { r.Body.Close() r.URL.Path, _ = url.JoinPath(r.URL.Path, id) obj = &object{b: b, id: r.URL.String()} - w.Write([]byte(id)) + ret, _ := json.Marshal(id) + w.Write([]byte(ret)) return case "GET": if obj == nil || r.URL.String() != obj.id { From eb875adeb0967a0a3cd7393d8eb2016a2642ac0f Mon Sep 17 00:00:00 2001 From: magodo Date: Sun, 19 May 2024 15:18:00 +0800 Subject: [PATCH 24/24] Add migration --- internal/dynamic/dynamic.go | 1 + internal/provider/migrate/operation_v0.go | 80 ++++++ internal/provider/migrate/resource_v0.go | 248 ++++++++++++++++++ .../provider/operation_jsonserver_test.go | 57 ++++ internal/provider/operation_resource.go | 2 + .../provider/operation_resource_upgrader.go | 85 ++++++ internal/provider/resource.go | 2 + internal/provider/resource_jsonserver_test.go | 59 +++++ internal/provider/resource_upgrader.go | 86 ++++++ 9 files changed, 620 insertions(+) create mode 100644 internal/provider/migrate/operation_v0.go create mode 100644 internal/provider/migrate/resource_v0.go create mode 100644 internal/provider/operation_resource_upgrader.go create mode 100644 internal/provider/resource_upgrader.go diff --git a/internal/dynamic/dynamic.go b/internal/dynamic/dynamic.go index e2d2157..5cff53a 100644 --- a/internal/dynamic/dynamic.go +++ b/internal/dynamic/dynamic.go @@ -280,6 +280,7 @@ func attrValueFromJSON(b []byte, typ attr.Type) (attr.Value, error) { // - []interface{}: tuple // - map[string]interface{}: object // - nil: null (dynamic) +// Note the argument has to be a valid JSON byte. E.g. it returns error on nil (0-length bytes). func FromJSONImplied(b []byte) (types.Dynamic, error) { _, v, err := attrValueFromJSONImplied(b) if err != nil { diff --git a/internal/provider/migrate/operation_v0.go b/internal/provider/migrate/operation_v0.go new file mode 100644 index 0000000..fab7b1d --- /dev/null +++ b/internal/provider/migrate/operation_v0.go @@ -0,0 +1,80 @@ +package migrate + +import ( + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var OperationSchemaV0 = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + }, + "path": schema.StringAttribute{ + Required: true, + }, + "method": schema.StringAttribute{ + Required: true, + }, + "body": schema.StringAttribute{ + Optional: true, + }, + "query": schema.MapAttribute{ + ElementType: types.ListType{ElemType: types.StringType}, + Optional: true, + }, + "header": schema.MapAttribute{ + ElementType: types.StringType, + Optional: true, + }, + + "precheck": precheckAttributeV0(true), + "poll": pollAttributeV0(), + "retry": retryAttributeV0(), + + "delete_method": schema.StringAttribute{ + Optional: true, + }, + + "delete_path": schema.StringAttribute{ + Optional: true, + }, + + "delete_body": schema.StringAttribute{ + Optional: true, + }, + + "precheck_delete": precheckAttributeV0(false), + "poll_delete": pollAttributeV0(), + "retry_delete": retryAttributeV0(), + + "output_attrs": schema.SetAttribute{ + Optional: true, + ElementType: types.StringType, + }, + + "output": schema.StringAttribute{ + Computed: true, + }, + }, +} + +type OperationDataV0 struct { + ID types.String `tfsdk:"id"` + Path types.String `tfsdk:"path"` + Method types.String `tfsdk:"method"` + Body types.String `tfsdk:"body"` + Query types.Map `tfsdk:"query"` + Header types.Map `tfsdk:"header"` + Precheck types.List `tfsdk:"precheck"` + Poll types.Object `tfsdk:"poll"` + Retry types.Object `tfsdk:"retry"` + DeleteMethod types.String `tfsdk:"delete_method"` + DeleteBody types.String `tfsdk:"delete_body"` + DeletePath types.String `tfsdk:"delete_path"` + PrecheckDelete types.List `tfsdk:"precheck_delete"` + PollDelete types.Object `tfsdk:"poll_delete"` + RetryDelete types.Object `tfsdk:"retry_delete"` + OutputAttrs types.Set `tfsdk:"output_attrs"` + Output types.String `tfsdk:"output"` +} diff --git a/internal/provider/migrate/resource_v0.go b/internal/provider/migrate/resource_v0.go new file mode 100644 index 0000000..664e6ee --- /dev/null +++ b/internal/provider/migrate/resource_v0.go @@ -0,0 +1,248 @@ +package migrate + +import ( + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func precheckAttributeV0(pathIsRequired bool) schema.ListNestedAttribute { + return schema.ListNestedAttribute{ + Optional: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "mutex": schema.StringAttribute{ + Optional: true, + }, + "api": schema.SingleNestedAttribute{ + Optional: true, + Attributes: map[string]schema.Attribute{ + "status_locator": schema.StringAttribute{ + Required: true, + }, + "status": schema.SingleNestedAttribute{ + Required: true, + Attributes: map[string]schema.Attribute{ + "success": schema.StringAttribute{ + Required: true, + }, + "pending": schema.ListAttribute{ + Optional: true, + ElementType: types.StringType, + }, + }, + }, + "path": schema.StringAttribute{ + Required: pathIsRequired, + Optional: !pathIsRequired, + }, + "query": schema.MapAttribute{ + ElementType: types.ListType{ElemType: types.StringType}, + Optional: true, + }, + "header": schema.MapAttribute{ + ElementType: types.StringType, + Optional: true, + }, + "default_delay_sec": schema.Int64Attribute{ + Optional: true, + Computed: true, + }, + }, + }, + }, + }, + } +} + +func pollAttributeV0() schema.SingleNestedAttribute { + return schema.SingleNestedAttribute{ + Optional: true, + Attributes: map[string]schema.Attribute{ + "status_locator": schema.StringAttribute{ + Required: true, + }, + "status": schema.SingleNestedAttribute{ + Required: true, + Attributes: map[string]schema.Attribute{ + "success": schema.StringAttribute{ + Required: true, + }, + "pending": schema.ListAttribute{ + Optional: true, + ElementType: types.StringType, + }, + }, + }, + "url_locator": schema.StringAttribute{ + Optional: true, + }, + "header": schema.MapAttribute{ + ElementType: types.StringType, + Optional: true, + }, + "default_delay_sec": schema.Int64Attribute{ + Optional: true, + Computed: true, + }, + }, + } +} + +func retryAttributeV0() schema.SingleNestedAttribute { + return schema.SingleNestedAttribute{ + Optional: true, + Attributes: map[string]schema.Attribute{ + "status_locator": schema.StringAttribute{ + Required: true, + }, + "status": schema.SingleNestedAttribute{ + Required: true, + Attributes: map[string]schema.Attribute{ + "success": schema.StringAttribute{ + Required: true, + }, + "pending": schema.ListAttribute{ + Optional: true, + ElementType: types.StringType, + }, + }, + }, + "count": schema.Int64Attribute{ + Optional: true, + }, + "wait_in_sec": schema.Int64Attribute{ + Optional: true, + }, + "max_wait_in_sec": schema.Int64Attribute{ + Optional: true, + }, + }, + } +} + +var ResourceSchemaV0 = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + }, + "path": schema.StringAttribute{ + Required: true, + }, + + "create_selector": schema.StringAttribute{ + Optional: true, + }, + "read_selector": schema.StringAttribute{ + Optional: true, + }, + + "read_path": schema.StringAttribute{ + Optional: true, + }, + "update_path": schema.StringAttribute{ + Optional: true, + }, + "delete_path": schema.StringAttribute{ + Optional: true, + }, + + "body": schema.StringAttribute{ + Required: true, + }, + + "poll_create": pollAttributeV0(), + "poll_update": pollAttributeV0(), + "poll_delete": pollAttributeV0(), + + "precheck_create": precheckAttributeV0(true), + "precheck_update": precheckAttributeV0(false), + "precheck_delete": precheckAttributeV0(false), + + "retry_create": retryAttributeV0(), + "retry_read": retryAttributeV0(), + "retry_update": retryAttributeV0(), + "retry_delete": retryAttributeV0(), + + "create_method": schema.StringAttribute{ + Optional: true, + }, + "update_method": schema.StringAttribute{ + Optional: true, + }, + "delete_method": schema.StringAttribute{ + Optional: true, + }, + "write_only_attrs": schema.ListAttribute{ + Optional: true, + ElementType: types.StringType, + }, + "merge_patch_disabled": schema.BoolAttribute{ + Optional: true, + }, + "query": schema.MapAttribute{ + ElementType: types.ListType{ElemType: types.StringType}, + Optional: true, + }, + "header": schema.MapAttribute{ + ElementType: types.StringType, + Optional: true, + }, + "check_existance": schema.BoolAttribute{ + Optional: true, + }, + "force_new_attrs": schema.SetAttribute{ + Optional: true, + ElementType: types.StringType, + }, + "output_attrs": schema.SetAttribute{ + Optional: true, + ElementType: types.StringType, + }, + "output": schema.StringAttribute{ + Computed: true, + }, + }, +} + +type ResourceDataV0 struct { + ID types.String `tfsdk:"id"` + + Path types.String `tfsdk:"path"` + + CreateSelector types.String `tfsdk:"create_selector"` + ReadSelector types.String `tfsdk:"read_selector"` + + ReadPath types.String `tfsdk:"read_path"` + UpdatePath types.String `tfsdk:"update_path"` + DeletePath types.String `tfsdk:"delete_path"` + + CreateMethod types.String `tfsdk:"create_method"` + UpdateMethod types.String `tfsdk:"update_method"` + DeleteMethod types.String `tfsdk:"delete_method"` + + PrecheckCreate types.List `tfsdk:"precheck_create"` + PrecheckUpdate types.List `tfsdk:"precheck_update"` + PrecheckDelete types.List `tfsdk:"precheck_delete"` + + Body types.String `tfsdk:"body"` + + PollCreate types.Object `tfsdk:"poll_create"` + PollUpdate types.Object `tfsdk:"poll_update"` + PollDelete types.Object `tfsdk:"poll_delete"` + + RetryCreate types.Object `tfsdk:"retry_create"` + RetryRead types.Object `tfsdk:"retry_read"` + RetryUpdate types.Object `tfsdk:"retry_update"` + RetryDelete types.Object `tfsdk:"retry_delete"` + + WriteOnlyAttributes types.List `tfsdk:"write_only_attrs"` + MergePatchDisabled types.Bool `tfsdk:"merge_patch_disabled"` + Query types.Map `tfsdk:"query"` + Header types.Map `tfsdk:"header"` + + CheckExistance types.Bool `tfsdk:"check_existance"` + ForceNewAttrs types.Set `tfsdk:"force_new_attrs"` + OutputAttrs types.Set `tfsdk:"output_attrs"` + + Output types.String `tfsdk:"output"` +} diff --git a/internal/provider/operation_jsonserver_test.go b/internal/provider/operation_jsonserver_test.go index 7ac71a8..bcff632 100644 --- a/internal/provider/operation_jsonserver_test.go +++ b/internal/provider/operation_jsonserver_test.go @@ -73,6 +73,31 @@ func TestOperation_JSONServer_withDelete(t *testing.T) { }) } +func TestOperation_JSONServer_MigrateV0ToV1(t *testing.T) { + d := newJsonServerOperation() + resource.Test(t, resource.TestCase{ + PreCheck: func() { d.precheck(t) }, + Steps: []resource.TestStep{ + { + ProtoV6ProviderFactories: nil, + ExternalProviders: map[string]resource.ExternalProvider{ + "restful": { + VersionConstraint: "= 0.13.2", + Source: "registry.terraform.io/magodo/restful", + }, + }, + Config: d.migrate_v0(), + }, + { + ProtoV6ProviderFactories: acceptance.ProviderFactory(), + ExternalProviders: nil, + Config: d.migrate_v1(), + PlanOnly: true, + }, + }, + }) +} + func (d jsonServerOperation) basic() string { return fmt.Sprintf(` provider "restful" { @@ -122,3 +147,35 @@ resource "restful_operation" "test" { } return tpl } + +func (d jsonServerOperation) migrate_v0() string { + return fmt.Sprintf(` +provider "restful" { + base_url = %q +} + +resource "restful_operation" "test" { + path = "posts" + method = "POST" + body = jsonencode({ + foo = "bar" + }) +} +`, d.url) +} + +func (d jsonServerOperation) migrate_v1() string { + return fmt.Sprintf(` +provider "restful" { + base_url = %q +} + +resource "restful_operation" "test" { + path = "posts" + method = "POST" + body = { + foo = "bar" + } +} +`, d.url) +} diff --git a/internal/provider/operation_resource.go b/internal/provider/operation_resource.go index dd2aab6..0831ffd 100644 --- a/internal/provider/operation_resource.go +++ b/internal/provider/operation_resource.go @@ -28,6 +28,7 @@ type OperationResource struct { } var _ resource.Resource = &OperationResource{} +var _ resource.ResourceWithUpgradeState = &OperationResource{} type operationResourceData struct { ID types.String `tfsdk:"id"` @@ -64,6 +65,7 @@ func (r *OperationResource) Schema(ctx context.Context, req resource.SchemaReque resp.Schema = schema.Schema{ Description: "`restful_operation` represents a one-time API call operation.", MarkdownDescription: "`restful_operation` represents a one-time API call operation.", + Version: 1, Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ Description: "The ID of the operation. Same as the `path`.", diff --git a/internal/provider/operation_resource_upgrader.go b/internal/provider/operation_resource_upgrader.go new file mode 100644 index 0000000..2b23f82 --- /dev/null +++ b/internal/provider/operation_resource_upgrader.go @@ -0,0 +1,85 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/magodo/terraform-provider-restful/internal/dynamic" + "github.com/magodo/terraform-provider-restful/internal/provider/migrate" +) + +func (r *OperationResource) UpgradeState(context.Context) map[int64]resource.StateUpgrader { + return map[int64]resource.StateUpgrader{ + 0: { + PriorSchema: &migrate.OperationSchemaV0, + StateUpgrader: func(ctx context.Context, req resource.UpgradeStateRequest, resp *resource.UpgradeStateResponse) { + var pd migrate.OperationDataV0 + + resp.Diagnostics.Append(req.State.Get(ctx, &pd)...) + + if resp.Diagnostics.HasError() { + return + } + + var err error + + body := types.DynamicNull() + if !pd.Body.IsNull() { + body, err = dynamic.FromJSONImplied([]byte(pd.Body.ValueString())) + if err != nil { + resp.Diagnostics.AddError( + "Upgrade State Error", + fmt.Sprintf(`Converting "body": %v`, err), + ) + } + } + + deleteBody := types.DynamicNull() + if !pd.DeleteBody.IsNull() { + deleteBody, err = dynamic.FromJSONImplied([]byte(pd.DeleteBody.ValueString())) + if err != nil { + resp.Diagnostics.AddError( + "Upgrade State Error", + fmt.Sprintf(`Converting "delete_body": %v`, err), + ) + } + } + + output := types.DynamicNull() + if !pd.Output.IsNull() { + output, err = dynamic.FromJSONImplied([]byte(pd.Output.ValueString())) + if err != nil { + resp.Diagnostics.AddError( + "Upgrade State Error", + fmt.Sprintf(`Converting "output": %v`, err), + ) + } + } + + upgradedStateData := operationResourceData{ + ID: pd.ID, + Path: pd.Path, + Method: pd.Method, + Body: body, + Query: pd.Query, + Header: pd.Header, + Precheck: pd.Precheck, + Poll: pd.Poll, + Retry: pd.Retry, + DeleteMethod: pd.DeleteMethod, + DeleteBody: deleteBody, + DeletePath: pd.DeletePath, + PrecheckDelete: pd.PrecheckDelete, + PollDelete: pd.PollDelete, + RetryDelete: pd.RetryDelete, + OutputAttrs: pd.OutputAttrs, + Output: output, + } + + resp.Diagnostics.Append(resp.State.Set(ctx, upgradedStateData)...) + }, + }, + } +} diff --git a/internal/provider/resource.go b/internal/provider/resource.go index 4263418..c174369 100644 --- a/internal/provider/resource.go +++ b/internal/provider/resource.go @@ -34,6 +34,7 @@ type Resource struct { } var _ resource.Resource = &Resource{} +var _ resource.ResourceWithUpgradeState = &Resource{} type resourceData struct { ID types.String `tfsdk:"id"` @@ -332,6 +333,7 @@ func (r *Resource) Schema(ctx context.Context, req resource.SchemaRequest, resp resp.Schema = schema.Schema{ Description: "`restful_resource` manages a restful resource.", MarkdownDescription: "`restful_resource` manages a restful resource.", + Version: 1, Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ Description: "The ID of the Resource.", diff --git a/internal/provider/resource_jsonserver_test.go b/internal/provider/resource_jsonserver_test.go index 3ba357e..4984e26 100644 --- a/internal/provider/resource_jsonserver_test.go +++ b/internal/provider/resource_jsonserver_test.go @@ -177,6 +177,33 @@ func TestResource_JSONServer_OutputAttrs(t *testing.T) { }) } +func TestResource_JSONServer_MigrateV0ToV1(t *testing.T) { + addr := "restful_resource.test" + d := newJsonServerData() + resource.Test(t, resource.TestCase{ + PreCheck: func() { d.precheck(t) }, + CheckDestroy: d.CheckDestroy(addr), + Steps: []resource.TestStep{ + { + ProtoV6ProviderFactories: nil, + ExternalProviders: map[string]resource.ExternalProvider{ + "restful": { + VersionConstraint: "= 0.13.2", + Source: "registry.terraform.io/magodo/restful", + }, + }, + Config: d.migrate_v0(), + }, + { + ProtoV6ProviderFactories: acceptance.ProviderFactory(), + ExternalProviders: nil, + Config: d.migrate_v1(), + PlanOnly: true, + }, + }, + }) +} + func (d jsonServerData) CheckDestroy(addr string) func(*terraform.State) error { return func(s *terraform.State) error { c, err := client.New(context.TODO(), d.url, nil) @@ -272,3 +299,35 @@ resource "restful_resource" "test" { } `, d.url) } + +func (d jsonServerData) migrate_v0() string { + return fmt.Sprintf(` +provider "restful" { + base_url = %q +} + +resource "restful_resource" "test" { + path = "posts" + body = jsonencode({ + foo = "bar" + }) + read_path = "$(path)/$(body.id)" +} +`, d.url) +} + +func (d jsonServerData) migrate_v1() string { + return fmt.Sprintf(` +provider "restful" { + base_url = %q +} + +resource "restful_resource" "test" { + path = "posts" + body = { + foo = "bar" + } + read_path = "$(path)/$(body.id)" +} +`, d.url) +} diff --git a/internal/provider/resource_upgrader.go b/internal/provider/resource_upgrader.go new file mode 100644 index 0000000..16ccb39 --- /dev/null +++ b/internal/provider/resource_upgrader.go @@ -0,0 +1,86 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/magodo/terraform-provider-restful/internal/dynamic" + "github.com/magodo/terraform-provider-restful/internal/provider/migrate" +) + +func (r *Resource) UpgradeState(context.Context) map[int64]resource.StateUpgrader { + return map[int64]resource.StateUpgrader{ + 0: { + PriorSchema: &migrate.ResourceSchemaV0, + StateUpgrader: func(ctx context.Context, req resource.UpgradeStateRequest, resp *resource.UpgradeStateResponse) { + var pd migrate.ResourceDataV0 + + resp.Diagnostics.Append(req.State.Get(ctx, &pd)...) + + if resp.Diagnostics.HasError() { + return + } + + var err error + + body := types.DynamicNull() + if !pd.Body.IsNull() { + body, err = dynamic.FromJSONImplied([]byte(pd.Body.ValueString())) + if err != nil { + resp.Diagnostics.AddError( + "Upgrade State Error", + fmt.Sprintf(`Converting "body": %v`, err), + ) + } + } + + output := types.DynamicNull() + if !output.IsNull() { + output, err = dynamic.FromJSONImplied([]byte(pd.Output.ValueString())) + if err != nil { + resp.Diagnostics.AddError( + "Upgrade State Error", + fmt.Sprintf(`Converting "output": %v`, err), + ) + } + } + + upgradedStateData := resourceData{ + ID: pd.ID, + Path: pd.Path, + CreateSelector: pd.CreateSelector, + ReadSelector: pd.ReadSelector, + ReadPath: pd.ReadPath, + UpdatePath: pd.UpdatePath, + DeletePath: pd.DeletePath, + CreateMethod: pd.CreateMethod, + UpdateMethod: pd.UpdateMethod, + DeleteMethod: pd.DeleteMethod, + PrecheckCreate: pd.PrecheckCreate, + PrecheckUpdate: pd.PrecheckUpdate, + PrecheckDelete: pd.PrecheckDelete, + Body: body, + PollCreate: pd.PollCreate, + PollUpdate: pd.PollUpdate, + PollDelete: pd.PollDelete, + RetryCreate: pd.RetryCreate, + RetryRead: pd.RetryRead, + RetryUpdate: pd.RetryUpdate, + RetryDelete: pd.RetryDelete, + WriteOnlyAttributes: pd.WriteOnlyAttributes, + MergePatchDisabled: pd.MergePatchDisabled, + Query: pd.Query, + Header: pd.Header, + CheckExistance: pd.CheckExistance, + ForceNewAttrs: pd.ForceNewAttrs, + OutputAttrs: pd.OutputAttrs, + Output: output, + } + + resp.Diagnostics.Append(resp.State.Set(ctx, upgradedStateData)...) + }, + }, + } +}