Skip to content

Commit

Permalink
Supports update_method (provider/resource level) to be PUT or `PA…
Browse files Browse the repository at this point in the history
…TCH`
  • Loading branch information
magodo committed Jun 22, 2022
1 parent a1b5c9c commit 66b32c5
Show file tree
Hide file tree
Showing 11 changed files with 133 additions and 11 deletions.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ The document of this provider is available on [Terraform Provider Registry](http

- Different authentication choices: HTTP auth (Basic, Bearer), API Key auth and OAuth2 (client credential, password credential).
- Support resources created via either `PUT` or `POST`
- Support resources created via either `PUT` or `PATCH`
- Support polling asynchronous operations
- Partial `body` tracking: only the specified properties of the resource in the `body` attribute is tracked for diffs

Expand All @@ -24,7 +25,7 @@ Another common use case is that the platform you are currently working on do not
- The API is expected to support the following HTTP methods:
- `POST`/`POST`: create the resource
- `GET`: read the resource
- `PUT`: update the resource
- `PUT`/`PATCH`: update the resource
- `DELETE`: remove the resource
- The API content type is `application/json`
- The resource should have a unique identifier (e.g. `/foos/foo1`).
Expand All @@ -34,6 +35,7 @@ Another common use case is that the platform you are currently working on do not
Regarding the users, as `terraform-provider-restful` is essentially just a terraform-wrapped API client, practitioners have to know the details of the API for the target platform quite well, e.g.:

- Wheter a resource is created via `PUT` or `POST`
- Wheter a resource is updated via `PUT` or `PATCH`
- Whether any query parameter is needed for CRUD
- For asynchronous operations, how to poll the result
- For resources that are created via `POST`, how to identify the `id`/`name` from the response
Expand Down
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ provider "restful" {
- `header` (Map of String) The header parameters that are applied to each request.
- `query` (Map of List of String) The query parameters that are applied to each request.
- `security` (Attributes) The OpenAPI security scheme that is be used for auth. (see [below for nested schema](#nestedatt--security))
- `update_method` (String) The method used to update the resource. Possible values are `PUT` and `PATCH`. When set to `PATCH`, only the changed part in the `body` will be used as the request body. Defaults to `PUT`.

<a id="nestedatt--security"></a>
### Nested Schema for `security`
Expand Down
1 change: 1 addition & 0 deletions docs/resources/restful_resource.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ resource "restful_resource" "rg" {
- `poll_delete` (Attributes) The polling option for the "Delete" operation (see [below for nested schema](#nestedatt--poll_delete))
- `poll_update` (Attributes) The polling option for the "Update" operation (see [below for nested schema](#nestedatt--poll_update))
- `query` (Map of List of String) The query parameters that are applied to each request. This overrides the `query` set in the provider block.
- `update_method` (String) The method used to update the resource. Possible values are `PUT` and `PATCH`. This overrides the `update_method` set in the provider block (defaults to PUT). When set to `PATCH`, only the changed part in the `body` will be used as the request body.
- `url_path` (String) The path (in [gjson syntax](https://github.com/tidwall/gjson/blob/master/SYNTAX.md)) to the id attribute in the response, which is only used during creation of the resource to be as the resource identifier. This is ignored when `create_method` is `PUT`. Either `name_path` or `url_path` needs to set when `create_method` is `POST`.
- `write_only_attrs` (List of String) 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.

Expand Down
1 change: 1 addition & 0 deletions examples/usecases/msgraph/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ provider "restful" {
scopes = ["https://graph.microsoft.com/.default"]
}
}
update_method = "PATCH"
}

resource "restful_resource" "group" {
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module github.com/magodo/terraform-provider-restful
go 1.18

require (
github.com/evanphx/json-patch v0.5.2
github.com/go-resty/resty/v2 v2.7.0
github.com/hashicorp/terraform-plugin-framework v0.9.0
github.com/hashicorp/terraform-plugin-go v0.9.1
Expand Down Expand Up @@ -31,6 +32,7 @@ require (
github.com/mitchellh/go-wordwrap v1.0.0 // indirect
github.com/mitchellh/mapstructure v1.4.3 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect
github.com/zclconf/go-cty v1.10.0 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k=
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
Expand Down Expand Up @@ -139,6 +141,7 @@ github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU=
github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
github.com/jhump/protoreflect v1.6.0 h1:h5jfMVslIg6l29nsMs0D8Wj17RDVdNYti0vDN/PZZoE=
github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 h1:DowS9hvgyYSX4TO5NpyC606/Z4SxnNYbT+WX27or6Ck=
Expand Down Expand Up @@ -178,6 +181,7 @@ github.com/nsf/jsondiff v0.0.0-20200515183724-f29ed568f4ce h1:RPclfga2SEJmgMmz2k
github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw=
github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
Expand Down
16 changes: 12 additions & 4 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,10 @@ func (c *Client) Read(ctx context.Context, path string, opt ReadOption) (*resty.
}

type UpdateOption struct {
Query Query
Header Header
PollOpt *PollOption
UpdateMethod string
Query Query
Header Header
PollOpt *PollOption
}

func (c *Client) Update(ctx context.Context, path string, body interface{}, opt UpdateOption) (*resty.Response, error) {
Expand All @@ -139,7 +140,14 @@ func (c *Client) Update(ctx context.Context, path string, body interface{}, opt
req.SetHeaders(opt.Header)
req = req.SetHeader("Content-Type", "application/json")

return req.Put(path)
switch opt.UpdateMethod {
case "PATCH":
return req.Patch(path)
case "PUT":
return req.Put(path)
default:
return nil, fmt.Errorf("unknown create method: %s", opt.UpdateMethod)
}
}

type DeleteOption struct {
Expand Down
9 changes: 7 additions & 2 deletions internal/provider/api_option.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (

type apiOption struct {
CreateMethod string
UpdateMethod string
Query client.Query
Header client.Header
}
Expand Down Expand Up @@ -119,8 +120,12 @@ func (opt apiOption) ForResourceRead(ctx context.Context, d resourceData) (*clie

func (opt apiOption) ForResourceUpdate(ctx context.Context, d resourceData) (*client.UpdateOption, diag.Diagnostics) {
out := client.UpdateOption{
Query: opt.Query.Clone().TakeOrSelf(ctx, d.Query),
Header: opt.Header.Clone().TakeOrSelf(ctx, d.Header),
UpdateMethod: opt.UpdateMethod,
Query: opt.Query.Clone().TakeOrSelf(ctx, d.Query),
Header: opt.Header.Clone().TakeOrSelf(ctx, d.Header),
}
if !d.UpdateMethod.Unknown && !d.UpdateMethod.Null {
out.UpdateMethod = d.UpdateMethod.Value
}

var diags diag.Diagnostics
Expand Down
15 changes: 15 additions & 0 deletions internal/provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ type providerData struct {
BaseURL string `tfsdk:"base_url"`
Security *securityData `tfsdk:"security"`
CreateMethod *string `tfsdk:"create_method"`
UpdateMethod *string `tfsdk:"update_method"`
Query map[string][]string `tfsdk:"query"`
Header map[string]string `tfsdk:"header"`
}
Expand Down Expand Up @@ -227,6 +228,15 @@ func (*provider) GetSchema(context.Context) (tfsdk.Schema, diag.Diagnostics) {
// Currently, we are setting the default value during the provider configuration.
Validators: []tfsdk.AttributeValidator{validator.StringInSlice("PUT", "POST")},
},
"update_method": {
Type: types.StringType,
Description: "The method used to update the resource. Possible values are `PUT` and `PATCH`. When set to `PATCH`, only the changed part in the `body` will be used as the request body. Defaults to `PUT`.",
MarkdownDescription: "The method used to update the resource. Possible values are `PUT` and `PATCH`. When set to `PATCH`, only the changed part in the `body` will be used as the request body. Defaults to `PUT`.",
Optional: true,
// Need a way to set the default value, plan modifier doesn't work here even it is Optional+Computed, because it is at provider level?
// Currently, we are setting the default value during the provider configuration.
Validators: []tfsdk.AttributeValidator{validator.StringInSlice("PUT", "PATCH")},
},
"query": {
Description: "The query parameters that are applied to each request.",
MarkdownDescription: "The query parameters that are applied to each request.",
Expand All @@ -248,6 +258,7 @@ func (p *provider) ValidateConfig(ctx context.Context, req tfsdk.ValidateProvide
BaseURL types.String `tfsdk:"base_url"`
Security types.Object `tfsdk:"security"`
CreateMethod types.String `tfsdk:"create_method"`
UpdateMethod types.String `tfsdk:"update_method"`
Query types.Map `tfsdk:"query"`
Header types.Map `tfsdk:"header"`
}
Expand Down Expand Up @@ -475,12 +486,16 @@ func (p *provider) Configure(ctx context.Context, req tfsdk.ConfigureProviderReq

p.apiOpt = apiOption{
CreateMethod: "POST",
UpdateMethod: "PUT",
Query: map[string][]string{},
Header: map[string]string{},
}
if config.CreateMethod != nil {
p.apiOpt.CreateMethod = *config.CreateMethod
}
if config.UpdateMethod != nil {
p.apiOpt.UpdateMethod = *config.UpdateMethod
}
if config.Query != nil {
p.apiOpt.Query = config.Query
}
Expand Down
42 changes: 38 additions & 4 deletions internal/provider/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/tidwall/gjson"

jsonpatch "github.com/evanphx/json-patch"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/tfsdk"
"github.com/hashicorp/terraform-plugin-framework/types"
Expand Down Expand Up @@ -141,6 +142,14 @@ func (r resourceType) GetSchema(_ context.Context) (tfsdk.Schema, diag.Diagnosti
Computed: true,
Validators: []tfsdk.AttributeValidator{validator.StringInSlice("PUT", "POST")},
},
"update_method": {
Description: "The method used to update the resource. Possible values are `PUT` and `PATCH`. This overrides the `update_method` set in the provider block (defaults to PUT). When set to `PATCH`, only the changed part in the `body` will be used as the request body.",
MarkdownDescription: "The method used to update the resource. Possible values are `PUT` and `PATCH`. This overrides the `update_method` set in the provider block (defaults to PUT). When set to `PATCH`, only the changed part in the `body` will be used as the request body.",
Type: types.StringType,
Optional: true,
Computed: true,
Validators: []tfsdk.AttributeValidator{validator.StringInSlice("PUT", "PATCH")},
},
"query": {
Description: "The query parameters that are applied to each request. This overrides the `query` set in the provider block.",
MarkdownDescription: "The query parameters that are applied to each request. This overrides the `query` set in the provider block.",
Expand Down Expand Up @@ -281,6 +290,7 @@ type resourceData struct {
PollUpdate types.Object `tfsdk:"poll_update"`
PollDelete types.Object `tfsdk:"poll_delete"`
CreateMethod types.String `tfsdk:"create_method"`
UpdateMethod types.String `tfsdk:"update_method"`
Query types.Map `tfsdk:"query"`
Header types.Map `tfsdk:"header"`
Output types.String `tfsdk:"output"`
Expand Down Expand Up @@ -413,6 +423,9 @@ func (r resource) Create(ctx context.Context, req tfsdk.CreateResourceRequest, r
plan.Query = opt.Query.ToTFValue()
plan.Header = opt.Header.ToTFValue()
plan.CreateMethod = types.String{Value: opt.CreateMethod}
if plan.UpdateMethod.IsUnknown() {
plan.UpdateMethod = types.String{Value: r.p.apiOpt.UpdateMethod}
}

// Set resource ID to state
plan.ID = types.String{Value: resourceId}
Expand Down Expand Up @@ -505,6 +518,7 @@ func (r resource) Read(ctx context.Context, req tfsdk.ReadResourceRequest, resp
)
return
}

// Set body, which is modified during read.
state.Body = types.String{Value: string(body)}

Expand All @@ -513,6 +527,11 @@ func (r resource) Read(ctx context.Context, req tfsdk.ReadResourceRequest, resp
createMethod = state.CreateMethod.Value
}

updateMethod := r.p.apiOpt.UpdateMethod
if state.UpdateMethod.Value != "" {
updateMethod = state.UpdateMethod.Value
}

// Set force new properties
switch createMethod {
case "POST":
Expand All @@ -525,6 +544,7 @@ func (r resource) Read(ctx context.Context, req tfsdk.ReadResourceRequest, resp
state.Query = opt.Query.ToTFValue()
state.Header = opt.Header.ToTFValue()
state.CreateMethod = types.String{Value: createMethod}
state.UpdateMethod = types.String{Value: updateMethod}

// Set computed attributes
state.Output = types.String{Value: string(b)}
Expand Down Expand Up @@ -561,7 +581,20 @@ func (r resource) Update(ctx context.Context, req tfsdk.UpdateResourceRequest, r

// Invoke API to Update the resource only when there are changes in the body.
if state.Body.Value != plan.Body.Value {
response, err := c.Update(ctx, state.ID.Value, plan.Body.Value, *opt)
body := plan.Body.Value
if opt.UpdateMethod == "PATCH" {
b, err := jsonpatch.CreateMergePatch([]byte(state.Body.Value), []byte(plan.Body.Value))
if err != nil {
resp.Diagnostics.AddError(
"Update failure",
fmt.Sprintf("failed to create a merge patch: %s", err.Error()),
)
return
}
body = string(b)
}

response, err := c.Update(ctx, state.ID.Value, body, *opt)
if err != nil {
resp.Diagnostics.AddError(
"Error to call update",
Expand Down Expand Up @@ -595,12 +628,13 @@ func (r resource) Update(ctx context.Context, req tfsdk.UpdateResourceRequest, r
}
}

// Set overridable attributes from option to state
// Set overridable attributes from option to state that might affect the read
plan.Query = opt.Query.ToTFValue()
plan.Header = opt.Header.ToTFValue()
if plan.CreateMethod.Unknown {
plan.CreateMethod = state.CreateMethod
if plan.CreateMethod.IsUnknown() {
plan.CreateMethod = types.String{Value: r.p.apiOpt.CreateMethod}
}
plan.UpdateMethod = types.String{Value: opt.UpdateMethod}

diags = resp.State.Set(ctx, plan)
resp.Diagnostics.Append(diags...)
Expand Down
49 changes: 49 additions & 0 deletions internal/provider/resource_msgraph_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,18 @@ func TestResource_MsGraph_User(t *testing.T) {
ImportStateVerify: false,
ImportStateIdFunc: d.userImportStateIdFunc(addr),
},
{
Config: d.userUpdate(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttrSet(addr, "output"),
),
},
{
ResourceName: addr,
ImportState: true,
ImportStateVerify: false,
ImportStateIdFunc: d.userImportStateIdFunc(addr),
},
},
})
}
Expand Down Expand Up @@ -121,6 +133,7 @@ provider "restful" {
scopes = ["https://graph.microsoft.com/.default"]
}
}
update_method = "PATCH"
}
resource "restful_resource" "test" {
Expand All @@ -144,6 +157,42 @@ resource "restful_resource" "test" {
`, d.url, d.clientId, d.clientSecret, d.tenantId, d.rd, d.orgDomain)
}

func (d msgraphData) userUpdate() string {
return fmt.Sprintf(`
provider "restful" {
base_url = %q
security = {
oauth2 = {
client_id = %q
client_secret = %q
token_url = "https://login.microsoftonline.com/%s/oauth2/v2.0/token"
scopes = ["https://graph.microsoft.com/.default"]
}
}
update_method = "PATCH"
}
resource "restful_resource" "test" {
path = "/users"
name_path = "id"
body = jsonencode({
accountEnabled = false
mailNickname = "AdeleV2"
displayName = "J.Doe"
userPrincipalName = "%d@%s"
passwordProfile = {
password = "SecretP@sswd99!"
}
})
write_only_attrs = [
"mailNickname",
"accountEnabled",
"passwordProfile",
]
}
`, d.url, d.clientId, d.clientSecret, d.tenantId, d.rd, d.orgDomain)
}

func (d msgraphData) userImportStateIdFunc(addr string) func(s *terraform.State) (string, error) {
return func(s *terraform.State) (string, error) {
return fmt.Sprintf(`{
Expand Down

0 comments on commit 66b32c5

Please sign in to comment.