Skip to content

Commit 5905319

Browse files
authored
Add internal/reflect package. (#30)
Add a package that will let us build reflect.Values from any Go type and populate them with the data from any tftypes.Value. This culminates in the reflect.Into function, which lets us have json.Unmarshal-esque behavior but for tftypes.Values.
1 parent 854b99b commit 5905319

18 files changed

+3059
-0
lines changed

internal/reflect/helpers.go

+96
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package reflect
2+
3+
import (
4+
"context"
5+
"errors"
6+
"reflect"
7+
"regexp"
8+
"strings"
9+
10+
"github.com/hashicorp/terraform-plugin-go/tftypes"
11+
)
12+
13+
// trueReflectValue returns the reflect.Value for `in` after derefencing all
14+
// the pointers and unwrapping all the interfaces. It's the concrete value
15+
// beneath it all.
16+
func trueReflectValue(val reflect.Value) reflect.Value {
17+
kind := val.Type().Kind()
18+
for kind == reflect.Interface || kind == reflect.Ptr {
19+
innerVal := val.Elem()
20+
if !innerVal.IsValid() {
21+
break
22+
}
23+
val = innerVal
24+
kind = val.Type().Kind()
25+
}
26+
return val
27+
}
28+
29+
// commaSeparatedString returns an English joining of the strings in `in`,
30+
// using "and" and commas as appropriate.
31+
func commaSeparatedString(in []string) string {
32+
switch len(in) {
33+
case 0:
34+
return ""
35+
case 1:
36+
return in[0]
37+
case 2:
38+
return strings.Join(in, " and ")
39+
default:
40+
in[len(in)-1] = "and " + in[len(in)-1]
41+
return strings.Join(in, ", ")
42+
}
43+
}
44+
45+
// getStructTags returns a map of Terraform field names to their position in
46+
// the tags of the struct `in`. `in` must be a struct.
47+
func getStructTags(ctx context.Context, in reflect.Value, path *tftypes.AttributePath) (map[string]int, error) {
48+
tags := map[string]int{}
49+
typ := trueReflectValue(in).Type()
50+
if typ.Kind() != reflect.Struct {
51+
return nil, path.NewErrorf("can't get struct tags of %s, is not a struct", in.Type())
52+
}
53+
for i := 0; i < typ.NumField(); i++ {
54+
field := typ.Field(i)
55+
if field.PkgPath != "" {
56+
// skip unexported fields
57+
continue
58+
}
59+
tag := field.Tag.Get(`tfsdk`)
60+
if tag == "-" {
61+
// skip explicitly excluded fields
62+
continue
63+
}
64+
if tag == "" {
65+
return nil, path.NewErrorf(`need a struct tag for "tfsdk" on %s`, field.Name)
66+
}
67+
path := path.WithAttributeName(tag)
68+
if !isValidFieldName(tag) {
69+
return nil, path.NewError(errors.New("invalid field name, must only use lowercase letters, underscores, and numbers, and must start with a letter"))
70+
}
71+
if other, ok := tags[tag]; ok {
72+
return nil, path.NewErrorf("can't use field name for both %s and %s", typ.Field(other).Name, field.Name)
73+
}
74+
tags[tag] = i
75+
}
76+
return tags, nil
77+
}
78+
79+
// isValidFieldName returns true if `name` can be used as a field name in a
80+
// Terraform resource or data source.
81+
func isValidFieldName(name string) bool {
82+
re := regexp.MustCompile("^[a-z][a-z0-9_]*$")
83+
return re.MatchString(name)
84+
}
85+
86+
// canBeNil returns true if `target`'s type can hold a nil value
87+
func canBeNil(target reflect.Value) bool {
88+
switch target.Kind() {
89+
case reflect.Ptr, reflect.Slice, reflect.Map, reflect.Interface:
90+
// these types can all hold nils
91+
return true
92+
default:
93+
// nothing else can be set to nil
94+
return false
95+
}
96+
}

internal/reflect/helpers_test.go

+258
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
package reflect
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"reflect"
7+
"testing"
8+
9+
"github.com/hashicorp/terraform-plugin-go/tftypes"
10+
)
11+
12+
func TestTrueReflectValue(t *testing.T) {
13+
t.Parallel()
14+
15+
var iface, otherIface interface{}
16+
var stru struct{}
17+
18+
// test that when nothing needs unwrapped, we get the right answer
19+
if got := trueReflectValue(reflect.ValueOf(stru)).Kind(); got != reflect.Struct {
20+
t.Errorf("Expected %s, got %s", reflect.Struct, got)
21+
}
22+
23+
// test that we can unwrap pointers
24+
if got := trueReflectValue(reflect.ValueOf(&stru)).Kind(); got != reflect.Struct {
25+
t.Errorf("Expected %s, got %s", reflect.Struct, got)
26+
}
27+
28+
// test that we can unwrap interfaces
29+
iface = stru
30+
if got := trueReflectValue(reflect.ValueOf(iface)).Kind(); got != reflect.Struct {
31+
t.Errorf("Expected %s, got %s", reflect.Struct, got)
32+
}
33+
34+
// test that we can unwrap pointers inside interfaces, and pointers to
35+
// interfaces with pointers inside them
36+
iface = &stru
37+
if got := trueReflectValue(reflect.ValueOf(iface)).Kind(); got != reflect.Struct {
38+
t.Errorf("Expected %s, got %s", reflect.Struct, got)
39+
}
40+
if got := trueReflectValue(reflect.ValueOf(&iface)).Kind(); got != reflect.Struct {
41+
t.Errorf("Expected %s, got %s", reflect.Struct, got)
42+
}
43+
44+
// test that we can unwrap pointers to interfaces inside other
45+
// interfaces, and pointers to interfaces inside pointers to
46+
// interfaces.
47+
otherIface = &iface
48+
if got := trueReflectValue(reflect.ValueOf(otherIface)).Kind(); got != reflect.Struct {
49+
t.Errorf("Expected %s, got %s", reflect.Struct, got)
50+
}
51+
if got := trueReflectValue(reflect.ValueOf(&otherIface)).Kind(); got != reflect.Struct {
52+
t.Errorf("Expected %s, got %s", reflect.Struct, got)
53+
}
54+
}
55+
56+
func TestCommaSeparatedString(t *testing.T) {
57+
t.Parallel()
58+
type testCase struct {
59+
input []string
60+
expected string
61+
}
62+
tests := map[string]testCase{
63+
"empty": {
64+
input: []string{},
65+
expected: "",
66+
},
67+
"oneWord": {
68+
input: []string{"red"},
69+
expected: "red",
70+
},
71+
"twoWords": {
72+
input: []string{"red", "blue"},
73+
expected: "red and blue",
74+
},
75+
"threeWords": {
76+
input: []string{"red", "blue", "green"},
77+
expected: "red, blue, and green",
78+
},
79+
"fourWords": {
80+
input: []string{"red", "blue", "green", "purple"},
81+
expected: "red, blue, green, and purple",
82+
},
83+
}
84+
for name, test := range tests {
85+
name, test := name, test
86+
t.Run(name, func(t *testing.T) {
87+
t.Parallel()
88+
got := commaSeparatedString(test.input)
89+
if got != test.expected {
90+
t.Errorf("Expected %q, got %q", test.expected, got)
91+
}
92+
})
93+
}
94+
}
95+
96+
func TestGetStructTags_success(t *testing.T) {
97+
t.Parallel()
98+
99+
type testStruct struct {
100+
ExportedAndTagged string `tfsdk:"exported_and_tagged"`
101+
unexported string //nolint:structcheck,unused
102+
unexportedAndTagged string `tfsdk:"unexported_and_tagged"`
103+
ExportedAndExcluded string `tfsdk:"-"`
104+
}
105+
106+
res, err := getStructTags(context.Background(), reflect.ValueOf(testStruct{}), tftypes.NewAttributePath())
107+
if err != nil {
108+
t.Errorf("Unexpected error: %s", err)
109+
}
110+
if len(res) != 1 {
111+
t.Errorf("Unexpected result: %v", res)
112+
}
113+
if res["exported_and_tagged"] != 0 {
114+
t.Errorf("Unexpected result: %v", res)
115+
}
116+
}
117+
118+
func TestGetStructTags_untagged(t *testing.T) {
119+
t.Parallel()
120+
type testStruct struct {
121+
ExportedAndUntagged string
122+
}
123+
_, err := getStructTags(context.Background(), reflect.ValueOf(testStruct{}), tftypes.NewAttributePath())
124+
if err == nil {
125+
t.Error("Expected error, got nil")
126+
}
127+
expected := `: need a struct tag for "tfsdk" on ExportedAndUntagged`
128+
if err.Error() != expected {
129+
t.Errorf("Expected error to be %q, got %q", expected, err.Error())
130+
}
131+
}
132+
133+
func TestGetStructTags_invalidTag(t *testing.T) {
134+
t.Parallel()
135+
type testStruct struct {
136+
InvalidTag string `tfsdk:"invalidTag"`
137+
}
138+
_, err := getStructTags(context.Background(), reflect.ValueOf(testStruct{}), tftypes.NewAttributePath())
139+
if err == nil {
140+
t.Errorf("Expected error, got nil")
141+
}
142+
expected := `AttributeName("invalidTag"): invalid field name, must only use lowercase letters, underscores, and numbers, and must start with a letter`
143+
if err.Error() != expected {
144+
t.Errorf("Expected error to be %q, got %q", expected, err.Error())
145+
}
146+
}
147+
148+
func TestGetStructTags_duplicateTag(t *testing.T) {
149+
t.Parallel()
150+
type testStruct struct {
151+
Field1 string `tfsdk:"my_field"`
152+
Field2 string `tfsdk:"my_field"`
153+
}
154+
_, err := getStructTags(context.Background(), reflect.ValueOf(testStruct{}), tftypes.NewAttributePath())
155+
if err == nil {
156+
t.Errorf("Expected error, got nil")
157+
}
158+
expected := `AttributeName("my_field"): can't use field name for both Field1 and Field2`
159+
if err.Error() != expected {
160+
t.Errorf("Expected error to be %q, got %q", expected, err.Error())
161+
}
162+
}
163+
164+
func TestGetStructTags_notAStruct(t *testing.T) {
165+
t.Parallel()
166+
var testStruct string
167+
168+
_, err := getStructTags(context.Background(), reflect.ValueOf(testStruct), tftypes.NewAttributePath())
169+
if err == nil {
170+
t.Errorf("Expected error, got nil")
171+
}
172+
expected := `: can't get struct tags of string, is not a struct`
173+
if err.Error() != expected {
174+
t.Errorf("Expected error to be %q, got %q", expected, err.Error())
175+
}
176+
}
177+
178+
func TestIsValidFieldName(t *testing.T) {
179+
t.Parallel()
180+
tests := map[string]bool{
181+
"": false,
182+
"a": true,
183+
"1": false,
184+
"1a": false,
185+
"a1": true,
186+
"A": false,
187+
"a-b": false,
188+
"a_b": true,
189+
}
190+
for in, expected := range tests {
191+
in, expected := in, expected
192+
t.Run(fmt.Sprintf("input=%q", in), func(t *testing.T) {
193+
t.Parallel()
194+
195+
result := isValidFieldName(in)
196+
if result != expected {
197+
t.Errorf("Expected %v, got %v", expected, result)
198+
}
199+
})
200+
}
201+
}
202+
203+
func TestCanBeNil_struct(t *testing.T) {
204+
t.Parallel()
205+
206+
var stru struct{}
207+
208+
got := canBeNil(reflect.ValueOf(stru))
209+
if got {
210+
t.Error("Expected structs to not be nillable, but canBeNil said they were")
211+
}
212+
}
213+
214+
func TestCanBeNil_structPointer(t *testing.T) {
215+
t.Parallel()
216+
217+
var stru struct{}
218+
struPtr := &stru
219+
220+
got := canBeNil(reflect.ValueOf(struPtr))
221+
if !got {
222+
t.Error("Expected pointers to structs to be nillable, but canBeNil said they weren't")
223+
}
224+
}
225+
226+
func TestCanBeNil_slice(t *testing.T) {
227+
t.Parallel()
228+
229+
slice := []string{}
230+
got := canBeNil(reflect.ValueOf(slice))
231+
if !got {
232+
t.Errorf("Expected slices to be nillable, but canBeNil said they weren't")
233+
}
234+
}
235+
236+
func TestCanBeNil_map(t *testing.T) {
237+
t.Parallel()
238+
239+
m := map[string]string{}
240+
got := canBeNil(reflect.ValueOf(m))
241+
if !got {
242+
t.Errorf("Expected maps to be nillable, but canBeNil said they weren't")
243+
}
244+
}
245+
246+
func TestCanBeNil_interface(t *testing.T) {
247+
t.Parallel()
248+
249+
type myStruct struct {
250+
Value interface{}
251+
}
252+
253+
var s myStruct
254+
got := canBeNil(reflect.ValueOf(s).FieldByName("Value"))
255+
if !got {
256+
t.Errorf("Expected interfaces to be nillable, but canBeNil said they weren't")
257+
}
258+
}

0 commit comments

Comments
 (0)