Skip to content

Commit 44e3c21

Browse files
authored
Merge pull request #512 from hashicorp/f-suppress-json-non-material-diffs-plan-modifier
Add `AttributePlanModifier` that suppresses semantically insignificant differences between JSON strings
2 parents 67a77ce + 3d47ef4 commit 44e3c21

File tree

2 files changed

+176
-0
lines changed

2 files changed

+176
-0
lines changed

internal/generic/jsonstring.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package generic
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"reflect"
8+
9+
"github.com/hashicorp/terraform-plugin-framework/tfsdk"
10+
"github.com/hashicorp/terraform-plugin-framework/types"
11+
)
12+
13+
type jsonStringAttributePlanModifier struct {
14+
tfsdk.AttributePlanModifier
15+
}
16+
17+
// A JSONString is a string containing a valid JSON document.
18+
// This plan modifier suppresses semantically insignificant differences.
19+
func JSONString() tfsdk.AttributePlanModifier {
20+
return jsonStringAttributePlanModifier{}
21+
}
22+
23+
func (attributePlanModifier jsonStringAttributePlanModifier) Description(_ context.Context) string {
24+
return "Suppresses semantically insignificant differences."
25+
}
26+
27+
func (attributePlanModifier jsonStringAttributePlanModifier) MarkdownDescription(ctx context.Context) string {
28+
return attributePlanModifier.Description(ctx)
29+
}
30+
31+
func (attributePlanModifier jsonStringAttributePlanModifier) Modify(ctx context.Context, request tfsdk.ModifyAttributePlanRequest, response *tfsdk.ModifyAttributePlanResponse) {
32+
if request.AttributeState == nil {
33+
response.AttributePlan = request.AttributePlan
34+
35+
return
36+
}
37+
38+
// If the current value is semantically equivalent to the planned value
39+
// then return the current value, else return the planned value.
40+
41+
var planned types.String
42+
diags := tfsdk.ValueAs(ctx, request.AttributePlan, &planned)
43+
44+
if diags.HasError() {
45+
response.Diagnostics = append(response.Diagnostics, diags...)
46+
47+
return
48+
}
49+
50+
plannedMap, err := expandJSONFromString(planned.Value)
51+
52+
if err != nil {
53+
response.Diagnostics.AddError(
54+
"Invalid JSON string",
55+
fmt.Sprintf("unable to unmarshal JSON: %s", err.Error()),
56+
)
57+
58+
return
59+
}
60+
61+
var current types.String
62+
diags = tfsdk.ValueAs(ctx, request.AttributeState, &current)
63+
64+
if diags.HasError() {
65+
response.Diagnostics = append(response.Diagnostics, diags...)
66+
67+
return
68+
}
69+
70+
currentMap, err := expandJSONFromString(current.Value)
71+
72+
if err != nil {
73+
response.Diagnostics.AddError(
74+
"Invalid JSON string",
75+
fmt.Sprintf("unable to unmarshal JSON: %s", err.Error()),
76+
)
77+
78+
return
79+
}
80+
81+
if reflect.DeepEqual(plannedMap, currentMap) {
82+
response.AttributePlan = request.AttributeState
83+
} else {
84+
response.AttributePlan = request.AttributePlan
85+
}
86+
}
87+
88+
func expandJSONFromString(s string) (map[string]interface{}, error) {
89+
var v map[string]interface{}
90+
91+
err := json.Unmarshal([]byte(s), &v)
92+
93+
return v, err
94+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package generic
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/google/go-cmp/cmp"
8+
"github.com/hashicorp/terraform-plugin-framework/attr"
9+
"github.com/hashicorp/terraform-plugin-framework/tfsdk"
10+
"github.com/hashicorp/terraform-plugin-framework/types"
11+
"github.com/hashicorp/terraform-plugin-go/tftypes"
12+
"github.com/hashicorp/terraform-provider-awscc/internal/tfresource"
13+
)
14+
15+
func TestJSONString(t *testing.T) {
16+
t.Parallel()
17+
18+
type testCase struct {
19+
plannedValue attr.Value
20+
currentValue attr.Value
21+
expectedValue attr.Value
22+
expectError bool
23+
}
24+
tests := map[string]testCase{
25+
"planned not string": {
26+
plannedValue: types.Int64{Value: 1},
27+
currentValue: types.String{Value: `{}`},
28+
expectError: true,
29+
},
30+
"current not string": {
31+
plannedValue: types.String{Value: `{}`},
32+
currentValue: types.Int64{Value: 1},
33+
expectError: true,
34+
},
35+
"exactly equal": {
36+
plannedValue: types.String{Value: `{}`},
37+
currentValue: types.String{Value: `{}`},
38+
expectedValue: types.String{Value: `{}`},
39+
},
40+
"leading and trailing whitespace": {
41+
plannedValue: types.String{Value: ` {}`},
42+
currentValue: types.String{Value: `{} `},
43+
expectedValue: types.String{Value: `{} `},
44+
},
45+
"not equal": {
46+
plannedValue: types.String{Value: `{"k1": 42}`},
47+
currentValue: types.String{Value: `{"k1": -1}`},
48+
expectedValue: types.String{Value: `{"k1": 42}`},
49+
},
50+
"fields reordered": {
51+
plannedValue: types.String{Value: `{"k2": ["v2", {"k3": true}], "k1": 42 }`},
52+
currentValue: types.String{Value: `{"k1": 42, "k2": ["v2", {"k3": true}]}`},
53+
expectedValue: types.String{Value: `{"k1": 42, "k2": ["v2", {"k3": true}]}`},
54+
},
55+
}
56+
57+
for name, test := range tests {
58+
name, test := name, test
59+
t.Run(name, func(t *testing.T) {
60+
ctx := context.TODO()
61+
request := tfsdk.ModifyAttributePlanRequest{
62+
AttributePath: tftypes.NewAttributePath().WithAttributeName("test"),
63+
AttributePlan: test.plannedValue,
64+
AttributeState: test.currentValue,
65+
}
66+
response := tfsdk.ModifyAttributePlanResponse{}
67+
JSONString().Modify(ctx, request, &response)
68+
69+
if !response.Diagnostics.HasError() && test.expectError {
70+
t.Fatal("expected error, got no error")
71+
}
72+
73+
if response.Diagnostics.HasError() && !test.expectError {
74+
t.Fatalf("got unexpected error: %s", tfresource.DiagsError(response.Diagnostics))
75+
}
76+
77+
if diff := cmp.Diff(response.AttributePlan, test.expectedValue); diff != "" {
78+
t.Errorf("unexpected diff (+wanted, -got): %s", diff)
79+
}
80+
})
81+
}
82+
}

0 commit comments

Comments
 (0)