diff --git a/docs/data-sources/resource.md b/docs/data-sources/resource.md index 622ed01..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` diff --git a/docs/resources/operation.md b/docs/resources/operation.md index fdc5c38..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` diff --git a/docs/resources/resource.md b/docs/resources/resource.md index aaeaa64..4935ac4 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` 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 } 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/dynamic/dynamic.go b/internal/dynamic/dynamic.go new file mode 100644 index 0000000..5cff53a --- /dev/null +++ b/internal/dynamic/dynamic.go @@ -0,0 +1,408 @@ +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) { + if d.IsNull() { + return nil, nil + } + return attrValueToJSON(d.UnderlyingValue()) +} + +func attrListToJSON(in []attr.Value) ([]json.RawMessage, error) { + 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) { + if val.IsNull() { + return json.Marshal(nil) + } + 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 + } + 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: + 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 + } + 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: + if b == nil || string(b) == "null" { + return types.SetNull(typ.ElemType), nil + } + 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: + 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: json=%d, type=%d", len(l), len(typ.ElemTypes)) + } + 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: + 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 + } + 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: + 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, attrType := range attrTypes { + val, err := attrValueFromJSON(m[k], attrType) + 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 + 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) + } +} + +// 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) +// 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 { + 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) + } +} + +// 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/dynamic/dynamic_test.go b/internal/dynamic/dynamic_test.go new file mode 100644 index 0000000..82e640c --- /dev/null +++ b/internal/dynamic/dynamic_test.go @@ -0,0 +1,716 @@ +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, + "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_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, + }, + "tuple": types.TupleType{ + ElemTypes: []attr.Type{ + types.BoolType, + types.StringType, + }, + }, + "tuple_empty": types.TupleType{ + ElemTypes: []attr.Type{}, + }, + "tuple_null": types.TupleType{ + ElemTypes: []attr.Type{ + types.BoolType, + types.StringType, + }, + }, + "map": types.MapType{ + ElemType: types.BoolType, + }, + "map_empty": 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_empty": types.ObjectType{ + AttrTypes: map[string]attr.Type{}, + }, + "object_null": types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "bool": types.BoolType, + "string": types.StringType, + }, + }, + }, + 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_empty": types.ListValueMust( + types.BoolType, + []attr.Value{}, + ), + "list_null": types.ListNull(types.BoolType), + "set": types.SetValueMust( + types.BoolType, + []attr.Value{ + types.BoolValue(true), + types.BoolValue(false), + }, + ), + "set_empty": types.SetValueMust( + types.BoolType, + []attr.Value{}, + ), + "set_null": types.SetNull(types.BoolType), + "tuple": types.TupleValueMust( + []attr.Type{ + types.BoolType, + types.StringType, + }, + []attr.Value{ + types.BoolValue(true), + types.StringValue("a"), + }, + ), + "tuple_empty": types.TupleValueMust( + []attr.Type{}, + []attr.Value{}, + ), + "tuple_null": types.TupleNull( + []attr.Type{ + types.BoolType, + types.StringType, + }, + ), + "map": types.MapValueMust( + types.BoolType, + map[string]attr.Value{ + "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{ + "bool": types.BoolType, + "string": types.StringType, + }, + map[string]attr.Value{ + "bool": types.BoolValue(true), + "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, + "string": types.StringType, + }, + ), + }, + ), + ) + + 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_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 +}` + + b, err := ToJSON(input) + require.NoError(t, err) + require.JSONEq(t, expect, string(b)) +} + +func TestFromJSON(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_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" + }, + "dynamic_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_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, + }, + "tuple": types.TupleType{ + ElemTypes: []attr.Type{ + types.BoolType, + types.StringType, + }, + }, + "tuple_empty": types.TupleType{ + ElemTypes: []attr.Type{}, + }, + "tuple_null": types.TupleType{ + ElemTypes: []attr.Type{ + types.BoolType, + types.StringType, + }, + }, + "map": types.MapType{ + ElemType: types.BoolType, + }, + "map_empty": 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_empty": types.ObjectType{ + AttrTypes: map[string]attr.Type{}, + }, + "object_null": types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "bool": types.BoolType, + "string": types.StringType, + }, + }, + "dynamic": types.DynamicType, + "dynamic_null": types.DynamicType, + }, + 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_empty": types.ListValueMust( + types.BoolType, + []attr.Value{}, + ), + "list_null": types.ListNull(types.BoolType), + "set": types.SetValueMust( + types.BoolType, + []attr.Value{ + types.BoolValue(true), + types.BoolValue(false), + }, + ), + "set_empty": types.SetValueMust( + types.BoolType, + []attr.Value{}, + ), + "set_null": types.SetNull(types.BoolType), + "tuple": types.TupleValueMust( + []attr.Type{ + types.BoolType, + types.StringType, + }, + []attr.Value{ + types.BoolValue(true), + types.StringValue("a"), + }, + ), + "tuple_empty": types.TupleValueMust( + []attr.Type{}, + []attr.Value{}, + ), + "tuple_null": types.TupleNull( + []attr.Type{ + types.BoolType, + types.StringType, + }, + ), + "map": types.MapValueMust( + types.BoolType, + map[string]attr.Value{ + "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{ + "bool": types.BoolType, + "string": types.StringType, + }, + map[string]attr.Value{ + "bool": types.BoolValue(true), + "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, + "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(), + }, + ), + ), + }, + { + 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, + }, + map[string]attr.Value{ + "str1": types.StringValue("a"), + }, + ), + ), + }, + { + 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{ + "str1": types.StringValue("a"), + "str2": types.StringNull(), + }, + ), + ), + }, + { + 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 { + 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_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( + types.ObjectValueMust( + map[string]attr.Type{ + "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_empty": types.TupleType{ + ElemTypes: []attr.Type{}, + }, + "list_null": types.DynamicType, + "set": types.TupleType{ + ElemTypes: []attr.Type{ + types.BoolType, + types.BoolType, + }, + }, + "set_empty": types.TupleType{ + ElemTypes: []attr.Type{}, + }, + "set_null": types.DynamicType, + "tuple": types.TupleType{ + ElemTypes: []attr.Type{ + types.BoolType, + 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{ + "bool": types.BoolType, + "string": types.StringType, + }, + }, + "object_empty": types.ObjectType{ + AttrTypes: map[string]attr.Type{}, + }, + "object_null": types.DynamicType, + }, + map[string]attr.Value{ + "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_empty": types.TupleValueMust( + []attr.Type{}, + []attr.Value{}, + ), + "list_null": types.DynamicNull(), + "set": types.TupleValueMust( + []attr.Type{ + types.BoolType, + types.BoolType, + }, + []attr.Value{ + types.BoolValue(true), + types.BoolValue(false), + }, + ), + "set_empty": types.TupleValueMust( + []attr.Type{}, + []attr.Value{}, + ), + "set_null": types.DynamicNull(), + "tuple": types.TupleValueMust( + []attr.Type{ + types.BoolType, + types.StringType, + }, + []attr.Value{ + types.BoolValue(true), + types.StringValue("a"), + }, + ), + "tuple_empty": types.TupleValueMust( + []attr.Type{}, + []attr.Value{}, + ), + "tuple_null": types.DynamicNull(), + "map": types.ObjectValueMust( + map[string]attr.Type{ + "a": types.BoolType, + }, + map[string]attr.Value{ + "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{ + "bool": types.BoolType, + "string": types.StringType, + }, + map[string]attr.Value{ + "bool": types.BoolValue(true), + "string": types.StringValue("a"), + }, + ), + "object_empty": types.ObjectValueMust( + map[string]attr.Type{}, + map[string]attr.Value{}, + ), + "object_null": types.DynamicNull(), + }, + ), + ), + }, + } + + 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) + }) + } +} diff --git a/internal/provider/data_source.go b/internal/provider/data_source.go index ec0bf73..f6c4ce1 100644 --- a/internal/provider/data_source.go +++ b/internal/provider/data_source.go @@ -11,6 +11,7 @@ import ( "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" ) type DataSource struct { @@ -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..129d993 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) })) @@ -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/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 fcaded1..bcff632 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,13 +66,38 @@ 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`), ), }, }, }) } +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" { @@ -82,9 +107,9 @@ provider "restful" { resource "restful_operation" "test" { path = "posts" method = "POST" - body = jsonencode({ + body = { foo = "bar" - }) + } } `, d.url) } @@ -98,7 +123,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,16 +134,48 @@ 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"] }` } 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 4ab31d3..0831ffd 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" ) @@ -27,25 +28,26 @@ type OperationResource struct { } var _ resource.Resource = &OperationResource{} +var _ resource.ResourceWithUpgradeState = &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) { @@ -63,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`.", @@ -88,13 +91,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 +132,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 +149,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 +201,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 +226,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 +261,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 +270,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 +278,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 +342,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/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 48ea0cd..959d2c8 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,18 +23,18 @@ 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" + "github.com/tidwall/sjson" ) -// Magic header used to indicate the value in the state is derived from import. -const __IMPORT_HEADER__ = "__RESTFUL_PROVIDER__" - type Resource struct { p *Provider } var _ resource.Resource = &Resource{} +var _ resource.ResourceWithUpgradeState = &Resource{} type resourceData struct { ID types.String `tfsdk:"id"` @@ -57,8 +56,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"` @@ -69,15 +67,16 @@ 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"` OutputAttrs types.Set `tfsdk:"output_attrs"` - Output types.String `tfsdk:"output"` + Output types.Dynamic `tfsdk:"output"` } type pollData struct { @@ -334,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.", @@ -388,13 +388,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 +407,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`, `POST` and `PATCH`. 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`, `POST` and `PATCH`. This overrides the `create_method` set in the provider block (defaults to POST).", @@ -440,6 +431,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`).", @@ -474,7 +471,7 @@ func (r *Resource) Schema(ctx context.Context, req resource.SchemaRequest, resp 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, @@ -490,13 +487,20 @@ func (r *Resource) ValidateConfig(ctx context.Context, req resource.ValidateConf 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(config.Body.ValueString(), ie.ValueString()).Exists() { + if !gjson.Get(string(b), ie.ValueString()).Exists() { resp.Diagnostics.AddError( "Invalid configuration", fmt.Sprintf(`Invalid path in "write_only_attrs": %s`, ie.String()), @@ -513,18 +517,27 @@ 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 } + 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() { + 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...) @@ -545,16 +558,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 +657,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 +681,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 @@ -735,7 +763,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, @@ -744,6 +772,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...) @@ -791,45 +823,67 @@ func (r Resource) Read(ctx context.Context, req resource.ReadRequest, resp *reso return } b = []byte(sb) - } - var writeOnlyAttributes []string - diags = state.WriteOnlyAttributes.ElementsAs(ctx, &writeOnlyAttributes, false) - resp.Diagnostics.Append(diags...) - if diags.HasError() { - return - } + if updateBody { + 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) - } - if err != nil { - resp.Diagnostics.AddError( - "Modifying `body` during Read", - err.Error(), - ) - return - } + // 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) + } - // Set body, which is modified during read. - state.Body = types.StringValue(string(body)) + 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 - 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) + fb, err := FilterAttrsInJSON(string(b), outputAttrs) if err != nil { resp.Diagnostics.AddError( "Filter `output` during Read", @@ -837,8 +891,18 @@ func (r Resource) Read(ctx context.Context, req resource.ReadRequest, resp *reso ) return } + b = []byte(fb) + } + + 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...) @@ -870,8 +934,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.ValueString() != plan.Body.ValueString() { + 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() { @@ -880,9 +961,16 @@ func (r Resource) Update(ctx context.Context, req resource.UpdateRequest, resp * } defer unlockFunc() - body := plan.Body.ValueString() 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(stateBodyJSON, planBody) if err != nil { resp.Diagnostics.AddError( "Update failure", @@ -890,23 +978,30 @@ func (r Resource) Update(ctx context.Context, req resource.UpdateRequest, resp * ) return } - body = string(b) + planBody = 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())) + 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(plan.UpdatePath.ValueString(), r.p.apiOpt.BaseURL.String(), plan.Path.ValueString(), 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 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 } } - 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", @@ -966,7 +1061,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, @@ -1000,12 +1095,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 } @@ -1070,25 +1172,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 interface{} + Body json.RawMessage `json:"body"` } func (Resource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { @@ -1123,19 +1215,15 @@ func (Resource) ImportState(ctx context.Context, req resource.ImportStateRequest return } - var body string - if imp.Body != nil { - 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)...) 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_dead_simple_json_server_test.go b/internal/provider/resource_dead_simple_json_server_test.go index 666ca3a..0e1f3db 100644 --- a/internal/provider/resource_dead_simple_json_server_test.go +++ b/internal/provider/resource_dead_simple_json_server_test.go @@ -53,7 +53,7 @@ func TestResource_DeadSimpleServer_ObjectArray(t *testing.T) { { Config: d.object_array(srv.URL, "foo"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttrSet(addr, "output"), + resource.TestCheckResourceAttrSet(addr, "output.#"), ), }, { @@ -68,7 +68,7 @@ func TestResource_DeadSimpleServer_ObjectArray(t *testing.T) { { Config: d.object_array(srv.URL, "bar"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttrSet(addr, "output"), + resource.TestCheckResourceAttrSet(addr, "output.#"), ), }, { @@ -123,7 +123,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.#"), ), }, { @@ -171,11 +171,11 @@ provider "restful" { resource "restful_resource" "test" { path = "test" create_method = "PUT" - body = jsonencode([ - { - foo = %q - } -]) + body = [ + { + foo = %q + } + ] } `, url, v) } @@ -190,7 +190,7 @@ resource "restful_resource" "test" { path = "test" create_method = "PUT" read_path = "$(path)/$(body)" - body = "{}" + body = {} } `, url) } diff --git a/internal/provider/resource_jsonserver_test.go b/internal/provider/resource_jsonserver_test.go index 0393e28..4984e26 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,13 +169,41 @@ 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"), ), }, }, }) } +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) @@ -207,9 +235,9 @@ provider "restful" { resource "restful_resource" "test" { path = "posts" - body = jsonencode({ + body = { foo = %q -}) + } read_path = "$(path)/$(body.id)" } `, d.url, v) @@ -225,9 +253,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 +271,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,15 +287,47 @@ 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"] } `, 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_msgraph_test.go b/internal/provider/resource_msgraph_test.go index 1f36036..18313f7 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,7 +163,7 @@ resource "restful_resource" "test" { displayName = "J.Doe" userPrincipalName = "%d@%s" - }) + } write_only_attrs = [ "mailNickname", "accountEnabled", @@ -194,7 +194,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,7 +202,7 @@ resource "restful_resource" "test" { } displayName = "J.Doe2" userPrincipalName = "%d@%s" - }) + } write_only_attrs = [ "mailNickname", "accountEnabled", 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)...) + }, + }, + } +}