Skip to content

Commit 16c5113

Browse files
committed
feat: support rendering strings in maps and slices
1 parent e1f2b58 commit 16c5113

File tree

3 files changed

+263
-0
lines changed

3 files changed

+263
-0
lines changed

render/render.go

+52
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,26 @@ func DefaultFuncMap() template.FuncMap {
3535
return funcs
3636
}
3737

38+
// Any attempts to render strings in the given value with data.
39+
// The input value is assumed to come from an untyped map[string]any
40+
// (typically from decoding unknown JSON or YAML).
41+
//
42+
// Delegates internally to [Map], [Slice], and [String]
43+
// (see the documentation for those functions for more info).
44+
// Other types are returned unchanged.
45+
func Any(value any, data any) (any, error) {
46+
switch casted := value.(type) {
47+
case map[string]any:
48+
return Map(casted, data)
49+
case []any:
50+
return Slice(casted, data)
51+
case string:
52+
return String(casted, data)
53+
default:
54+
return casted, nil
55+
}
56+
}
57+
3858
// File renders the file at path with data.
3959
func File(path string, data any) (string, error) {
4060
name := filepath.Base(path)
@@ -45,6 +65,38 @@ func File(path string, data any) (string, error) {
4565
return execute(t, data)
4666
}
4767

68+
// Map recursively renders the keys and values of the given map with data.
69+
func Map(values map[string]any, data any) (map[string]any, error) {
70+
rendered := map[string]any{}
71+
for k, v := range values {
72+
ka, err := Any(k, data)
73+
if err != nil {
74+
return nil, err
75+
}
76+
// We know if `k` was originally a string, `Any` should return as one.
77+
k = ka.(string)
78+
79+
va, err := Any(v, data)
80+
if err != nil {
81+
return nil, err
82+
}
83+
rendered[k] = va
84+
}
85+
return rendered, nil
86+
}
87+
88+
// Map recursively renders the elements of the given slice with data.
89+
func Slice(values []any, data any) ([]any, error) {
90+
var err error
91+
for idx := range values {
92+
values[idx], err = Any(values[idx], data)
93+
if err != nil {
94+
return nil, err
95+
}
96+
}
97+
return values, nil
98+
}
99+
48100
// String renders the template string with data.
49101
func String(s string, data any) (string, error) {
50102
t, err := template.New("render.String").Funcs(FuncMap).Parse(s)

render/render_test.go

+194
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,206 @@
11
package render
22

33
import (
4+
"encoding/json"
5+
"os"
46
"path/filepath"
57
"testing"
68

79
"github.com/stretchr/testify/assert"
810
)
911

12+
func TestAny(t *testing.T) {
13+
tests := []struct {
14+
desc string
15+
value any
16+
data map[string]any
17+
expected any
18+
assertion assert.ErrorAssertionFunc
19+
}{
20+
// non-string scalars should get passed through unchanged.
21+
{desc: "[nil] noop", value: nil, expected: nil, assertion: assert.NoError},
22+
{desc: "[int] noop", value: 123, expected: 123, assertion: assert.NoError},
23+
{desc: "[float] noop", value: 1.2, expected: 1.2, assertion: assert.NoError},
24+
{desc: "[bool] noop", value: true, expected: true, assertion: assert.NoError},
25+
26+
{
27+
desc: "[string] should pass non-template strings through unchanged",
28+
value: "Howdy 👋",
29+
expected: "Howdy 👋",
30+
assertion: assert.NoError,
31+
},
32+
{
33+
desc: "[string] should render template strings",
34+
value: "Hello, {{ .Name }} 👋",
35+
data: map[string]any{
36+
"Name": "World",
37+
},
38+
expected: "Hello, World 👋",
39+
assertion: assert.NoError,
40+
},
41+
{
42+
desc: "[string] should return template errors",
43+
value: `{{ fail "boom" }}`,
44+
expected: "",
45+
assertion: assert.Error,
46+
},
47+
48+
{
49+
desc: "[slice] should pass non-template values through unchanged",
50+
value: []any{"111", 222, "333"},
51+
expected: []any{"111", 222, "333"},
52+
assertion: assert.NoError,
53+
},
54+
{
55+
desc: "[slice] should render template strings recursively",
56+
value: []any{
57+
"{{ .aaa }}",
58+
[]any{
59+
"{{ .bbb }}",
60+
[]any{
61+
"{{ .ccc }}",
62+
},
63+
},
64+
},
65+
data: map[string]any{
66+
"aaa": "AAA",
67+
"bbb": "BBB",
68+
"ccc": "CCC",
69+
},
70+
expected: []any{
71+
"AAA",
72+
[]any{
73+
"BBB",
74+
[]any{
75+
"CCC",
76+
},
77+
},
78+
},
79+
assertion: assert.NoError,
80+
},
81+
{
82+
desc: "[slice] should return template errors",
83+
value: []any{"111", "222", `{{ fail "boom" }}`},
84+
expected: []any(nil),
85+
assertion: assert.Error,
86+
},
87+
88+
{
89+
desc: "[map] should pass non-template values through unchanged",
90+
value: map[string]any{
91+
"aaa": "aaa one",
92+
"bbb": []any{"bbb one"},
93+
"ccc": map[string]any{
94+
"ccc.1": "ccc one",
95+
"ccc.2": []any{"ccc two"},
96+
},
97+
},
98+
expected: map[string]any{
99+
"aaa": "aaa one",
100+
"bbb": []any{"bbb one"},
101+
"ccc": map[string]any{
102+
"ccc.1": "ccc one",
103+
"ccc.2": []any{"ccc two"},
104+
},
105+
},
106+
assertion: assert.NoError,
107+
},
108+
{
109+
desc: "[map] should render template strings recursively",
110+
value: map[string]any{
111+
"{{ .aaa }}": "{{ .aaa }} one",
112+
"{{ .bbb }}": []any{"{{ .bbb }} one"},
113+
"{{ .ccc }}": map[string]any{
114+
"{{ .ccc }}.1": "{{ .ccc }} one",
115+
"{{ .ccc }}.2": []any{"{{ .ccc }} two"},
116+
},
117+
},
118+
data: map[string]any{
119+
"aaa": "AAA",
120+
"bbb": "BBB",
121+
"ccc": "CCC",
122+
},
123+
expected: map[string]any{
124+
"AAA": "AAA one",
125+
"BBB": []any{"BBB one"},
126+
"CCC": map[string]any{
127+
"CCC.1": "CCC one",
128+
"CCC.2": []any{"CCC two"},
129+
},
130+
},
131+
assertion: assert.NoError,
132+
},
133+
{
134+
desc: "[map] should return template errors from keys",
135+
value: map[string]any{
136+
`{{ fail "boom" }}`: "aaa",
137+
},
138+
expected: map[string]any(nil),
139+
assertion: assert.Error,
140+
},
141+
{
142+
desc: "[map] should return template errors from values",
143+
value: map[string]any{
144+
"aaa": "aaa",
145+
"bbb": map[string]any{
146+
"ccc": `{{ fail "boom" }}`,
147+
},
148+
},
149+
expected: map[string]any(nil),
150+
assertion: assert.Error,
151+
},
152+
}
153+
for _, tt := range tests {
154+
t.Run(tt.desc, func(t *testing.T) {
155+
actual, err := Any(tt.value, tt.data)
156+
if tt.assertion != nil {
157+
tt.assertion(t, err)
158+
}
159+
assert.Equal(t, tt.expected, actual)
160+
})
161+
}
162+
}
163+
164+
// Somewhat of an integration test for the primary use case.
165+
func TestAny_WithDataFromJSON(t *testing.T) {
166+
// Decode example.json into an untyped map
167+
var values map[string]any
168+
buf, err := os.ReadFile(filepath.Join("testdata", "example.json"))
169+
if err != nil {
170+
panic(err)
171+
}
172+
err = json.Unmarshal(buf, &values)
173+
if err != nil {
174+
panic(err)
175+
}
176+
177+
// Render the map w/ some data.
178+
rendered, err := Any(values, map[string]any{
179+
"aaa": "AAA",
180+
"bbb": "BBB",
181+
"ccc": "CCC",
182+
})
183+
184+
assert.NoError(t, err)
185+
assert.Equal(t, map[string]any{
186+
"AAA": "AAA",
187+
"BBB": []any{
188+
"BBB.1",
189+
"BBB.2",
190+
[]any{
191+
"BBB.3.1",
192+
"BBB.3.2",
193+
},
194+
},
195+
"CCC": map[string]any{
196+
"CCC.1": map[string]any{
197+
"CCC.1.1": "CCC.1.1",
198+
"CCC.1.2": "CCC.1.2",
199+
},
200+
},
201+
}, rendered)
202+
}
203+
10204
func TestRenderFile(t *testing.T) {
11205
tests := []struct {
12206
Desc string

render/testdata/example.json

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"{{ .aaa }}": "{{ .aaa }}",
3+
"{{ .bbb }}": [
4+
"{{ .bbb }}.1",
5+
"{{ .bbb }}.2",
6+
[
7+
"{{ .bbb }}.3.1",
8+
"{{ .bbb }}.3.2"
9+
]
10+
],
11+
"{{ .ccc }}": {
12+
"{{ .ccc }}.1": {
13+
"{{ .ccc }}.1.1": "{{ .ccc }}.1.1",
14+
"{{ .ccc }}.1.2": "{{ .ccc }}.1.2"
15+
}
16+
}
17+
}

0 commit comments

Comments
 (0)