Skip to content

Commit 55b607a

Browse files
ext/tryfunc: Extension functions for error handling
The try(...) and can(...) functions are intended to make it more convenient to work with deep data structures of unknown shape, by allowing a caller to concisely try a complex traversal operation against a value without having to guard against each possible failure mode individually. These rely on the customdecode extension to get access to their argument expressions directly, rather than only the results of evaluating those expressions. The expressions can then be evaluated in a controlled manner so that any resulting errors can be recognized and suppressed as appropriate.
1 parent d8ae04b commit 55b607a

File tree

3 files changed

+387
-0
lines changed

3 files changed

+387
-0
lines changed

ext/tryfunc/README.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# "Try" and "can" functions
2+
3+
This Go package contains two `cty` functions intended for use in an
4+
`hcl.EvalContext` when evaluating HCL native syntax expressions.
5+
6+
The first function `try` attempts to evaluate each of its argument expressions
7+
in order until one produces a result without any errors.
8+
9+
```hcl
10+
try(non_existent_variable, 2) # returns 2
11+
```
12+
13+
If none of the expressions succeed, the function call fails with all of the
14+
errors it encountered.
15+
16+
The second function `can` is similar except that it ignores the result of
17+
the given expression altogether and simply returns `true` if the expression
18+
produced a successful result or `false` if it produced errors.
19+
20+
Both of these are primarily intended for working with deep data structures
21+
which might not have a dependable shape. For example, we can use `try` to
22+
attempt to fetch a value from deep inside a data structure but produce a
23+
default value if any step of the traversal fails:
24+
25+
```hcl
26+
result = try(foo.deep[0].lots.of["traversals"], null)
27+
```
28+
29+
The final result to `try` should generally be some sort of constant value that
30+
will always evaluate successfully.
31+
32+
## Using these functions
33+
34+
Languages built on HCL can make `try` and `can` available to user code by
35+
exporting them in the `hcl.EvalContext` used for expression evaluation:
36+
37+
```go
38+
ctx := &hcl.EvalContext{
39+
Functions: map[string]function.Function{
40+
"try": tryfunc.TryFunc,
41+
"can": tryfunc.CanFunc,
42+
},
43+
}
44+
```

ext/tryfunc/tryfunc.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
// Package tryfunc contains some optional functions that can be exposed in
2+
// HCL-based languages to allow authors to test whether a particular expression
3+
// can succeed and take dynamic action based on that result.
4+
//
5+
// These functions are implemented in terms of the customdecode extension from
6+
// the sibling directory "customdecode", and so they are only useful when
7+
// used within an HCL EvalContext. Other systems using cty functions are
8+
// unlikely to support the HCL-specific "customdecode" extension.
9+
package tryfunc
10+
11+
import (
12+
"errors"
13+
"fmt"
14+
"strings"
15+
16+
"github.com/hashicorp/hcl/v2"
17+
"github.com/hashicorp/hcl/v2/ext/customdecode"
18+
"github.com/zclconf/go-cty/cty"
19+
"github.com/zclconf/go-cty/cty/function"
20+
)
21+
22+
// TryFunc is a variadic function that tries to evaluate all of is arguments
23+
// in sequence until one succeeds, in which case it returns that result, or
24+
// returns an error if none of them succeed.
25+
var TryFunc function.Function
26+
27+
// CanFunc tries to evaluate the expression given in its first argument.
28+
var CanFunc function.Function
29+
30+
func init() {
31+
TryFunc = function.New(&function.Spec{
32+
VarParam: &function.Parameter{
33+
Name: "expressions",
34+
Type: customdecode.ExpressionClosureType,
35+
},
36+
Type: func(args []cty.Value) (cty.Type, error) {
37+
v, err := try(args)
38+
if err != nil {
39+
return cty.NilType, err
40+
}
41+
return v.Type(), nil
42+
},
43+
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
44+
return try(args)
45+
},
46+
})
47+
CanFunc = function.New(&function.Spec{
48+
Params: []function.Parameter{
49+
{
50+
Name: "expression",
51+
Type: customdecode.ExpressionClosureType,
52+
},
53+
},
54+
Type: function.StaticReturnType(cty.Bool),
55+
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
56+
return can(args[0])
57+
},
58+
})
59+
}
60+
61+
func try(args []cty.Value) (cty.Value, error) {
62+
if len(args) == 0 {
63+
return cty.NilVal, errors.New("at least one argument is required")
64+
}
65+
66+
// We'll collect up all of the diagnostics we encounter along the way
67+
// and report them all if none of the expressions succeed, so that the
68+
// user might get some hints on how to make at least one succeed.
69+
var diags hcl.Diagnostics
70+
for _, arg := range args {
71+
closure := customdecode.ExpressionClosureFromVal(arg)
72+
if dependsOnUnknowns(closure.Expression, closure.EvalContext) {
73+
// We can't safely decide if this expression will succeed yet,
74+
// and so our entire result must be unknown until we have
75+
// more information.
76+
return cty.DynamicVal, nil
77+
}
78+
79+
v, moreDiags := closure.Value()
80+
diags = append(diags, moreDiags...)
81+
if moreDiags.HasErrors() {
82+
continue // try the next one, if there is one to try
83+
}
84+
return v, nil // ignore any accumulated diagnostics if one succeeds
85+
}
86+
87+
// If we fall out here then none of the expressions succeeded, and so
88+
// we must have at least one diagnostic and we'll return all of them
89+
// so that the user can see the errors related to whichever one they
90+
// were expecting to have succeeded in this case.
91+
//
92+
// Because our function must return a single error value rather than
93+
// diagnostics, we'll construct a suitable error message string
94+
// that will make sense in the context of the function call failure
95+
// diagnostic HCL will eventually wrap this in.
96+
var buf strings.Builder
97+
buf.WriteString("no expression succeeded:\n")
98+
for _, diag := range diags {
99+
if diag.Subject != nil {
100+
buf.WriteString(fmt.Sprintf("- %s (at %s)\n %s\n", diag.Summary, diag.Subject, diag.Detail))
101+
} else {
102+
buf.WriteString(fmt.Sprintf("- %s\n %s\n", diag.Summary, diag.Detail))
103+
}
104+
}
105+
buf.WriteString("\nAt least one expression must produce a successful result")
106+
return cty.NilVal, errors.New(buf.String())
107+
}
108+
109+
func can(arg cty.Value) (cty.Value, error) {
110+
closure := customdecode.ExpressionClosureFromVal(arg)
111+
if dependsOnUnknowns(closure.Expression, closure.EvalContext) {
112+
// Can't decide yet, then.
113+
return cty.UnknownVal(cty.Bool), nil
114+
}
115+
116+
_, diags := closure.Value()
117+
if diags.HasErrors() {
118+
return cty.False, nil
119+
}
120+
return cty.True, nil
121+
}
122+
123+
// dependsOnUnknowns returns true if any of the variables that the given
124+
// expression might access are unknown values or contain unknown values.
125+
//
126+
// This is a conservative result that prefers to return true if there's any
127+
// chance that the expression might derive from an unknown value during its
128+
// evaluation; it is likely to produce false-positives for more complex
129+
// expressions involving deep data structures.
130+
func dependsOnUnknowns(expr hcl.Expression, ctx *hcl.EvalContext) bool {
131+
for _, traversal := range expr.Variables() {
132+
val, diags := traversal.TraverseAbs(ctx)
133+
if diags.HasErrors() {
134+
// If the traversal returned a definitive error then it must
135+
// not traverse through any unknowns.
136+
continue
137+
}
138+
if !val.IsWhollyKnown() {
139+
// The value will be unknown if either it refers directly to
140+
// an unknown value or if the traversal moves through an unknown
141+
// collection. We're using IsWhollyKnown, so this also catches
142+
// situations where the traversal refers to a compound data
143+
// structure that contains any unknown values. That's important,
144+
// because during evaluation the expression might evaluate more
145+
// deeply into this structure and encounter the unknowns.
146+
return true
147+
}
148+
}
149+
return false
150+
}

ext/tryfunc/tryfunc_test.go

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
package tryfunc
2+
3+
import (
4+
"testing"
5+
6+
"github.com/hashicorp/hcl/v2"
7+
"github.com/hashicorp/hcl/v2/hclsyntax"
8+
"github.com/zclconf/go-cty/cty"
9+
"github.com/zclconf/go-cty/cty/function"
10+
)
11+
12+
func TestTryFunc(t *testing.T) {
13+
tests := map[string]struct {
14+
expr string
15+
vars map[string]cty.Value
16+
want cty.Value
17+
wantErr string
18+
}{
19+
"one argument succeeds": {
20+
`try(1)`,
21+
nil,
22+
cty.NumberIntVal(1),
23+
``,
24+
},
25+
"two arguments, first succeeds": {
26+
`try(1, 2)`,
27+
nil,
28+
cty.NumberIntVal(1),
29+
``,
30+
},
31+
"two arguments, first fails": {
32+
`try(nope, 2)`,
33+
nil,
34+
cty.NumberIntVal(2),
35+
``,
36+
},
37+
"two arguments, first depends on unknowns": {
38+
`try(unknown, 2)`,
39+
map[string]cty.Value{
40+
"unknown": cty.UnknownVal(cty.Number),
41+
},
42+
cty.DynamicVal, // can't proceed until first argument is known
43+
``,
44+
},
45+
"two arguments, first succeeds and second depends on unknowns": {
46+
`try(1, unknown)`,
47+
map[string]cty.Value{
48+
"unknown": cty.UnknownVal(cty.Number),
49+
},
50+
cty.NumberIntVal(1), // we know 1st succeeds, so it doesn't matter that 2nd is unknown
51+
``,
52+
},
53+
"two arguments, first depends on unknowns deeply": {
54+
`try(has_unknowns, 2)`,
55+
map[string]cty.Value{
56+
"has_unknowns": cty.ListVal([]cty.Value{cty.UnknownVal(cty.Bool)}),
57+
},
58+
cty.DynamicVal, // can't proceed until first argument is wholly known
59+
``,
60+
},
61+
"two arguments, first traverses through an unkown": {
62+
`try(unknown.baz, 2)`,
63+
map[string]cty.Value{
64+
"unknown": cty.UnknownVal(cty.Map(cty.String)),
65+
},
66+
cty.DynamicVal, // can't proceed until first argument is wholly known
67+
``,
68+
},
69+
"three arguments, all fail": {
70+
`try(this, that, this_thing_in_particular)`,
71+
nil,
72+
cty.NumberIntVal(2),
73+
// The grammar of this stringification of the message is unfortunate,
74+
// but caller can type-assert our result to get the original
75+
// diagnostics directly in order to produce a better result.
76+
`test.hcl:1,1-5: Error in function call; Call to function "try" failed: no expression succeeded:
77+
- Variables not allowed (at test.hcl:1,5-9)
78+
Variables may not be used here.
79+
- Variables not allowed (at test.hcl:1,11-15)
80+
Variables may not be used here.
81+
- Variables not allowed (at test.hcl:1,17-41)
82+
Variables may not be used here.
83+
84+
At least one expression must produce a successful result.`,
85+
},
86+
"no arguments": {
87+
`try()`,
88+
nil,
89+
cty.NilVal,
90+
`test.hcl:1,1-5: Error in function call; Call to function "try" failed: at least one argument is required.`,
91+
},
92+
}
93+
94+
for k, test := range tests {
95+
t.Run(k, func(t *testing.T) {
96+
expr, diags := hclsyntax.ParseExpression([]byte(test.expr), "test.hcl", hcl.Pos{Line: 1, Column: 1})
97+
if diags.HasErrors() {
98+
t.Fatalf("unexpected problems: %s", diags.Error())
99+
}
100+
101+
ctx := &hcl.EvalContext{
102+
Variables: test.vars,
103+
Functions: map[string]function.Function{
104+
"try": TryFunc,
105+
},
106+
}
107+
108+
got, err := expr.Value(ctx)
109+
110+
if err != nil {
111+
if test.wantErr != "" {
112+
if got, want := err.Error(), test.wantErr; got != want {
113+
t.Errorf("wrong error\ngot: %s\nwant: %s", got, want)
114+
}
115+
} else {
116+
t.Errorf("unexpected error\ngot: %s\nwant: <nil>", err)
117+
}
118+
return
119+
}
120+
if test.wantErr != "" {
121+
t.Errorf("wrong error\ngot: <nil>\nwant: %s", test.wantErr)
122+
}
123+
124+
if !test.want.RawEquals(got) {
125+
t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.want)
126+
}
127+
})
128+
}
129+
}
130+
131+
func TestCanFunc(t *testing.T) {
132+
tests := map[string]struct {
133+
expr string
134+
vars map[string]cty.Value
135+
want cty.Value
136+
}{
137+
"succeeds": {
138+
`can(1)`,
139+
nil,
140+
cty.True,
141+
},
142+
"fails": {
143+
`can(nope)`,
144+
nil,
145+
cty.False,
146+
},
147+
"simple unknown": {
148+
`can(unknown)`,
149+
map[string]cty.Value{
150+
"unknown": cty.UnknownVal(cty.Number),
151+
},
152+
cty.UnknownVal(cty.Bool),
153+
},
154+
"traversal through unknown": {
155+
`can(unknown.foo)`,
156+
map[string]cty.Value{
157+
"unknown": cty.UnknownVal(cty.Map(cty.Number)),
158+
},
159+
cty.UnknownVal(cty.Bool),
160+
},
161+
"deep unknown": {
162+
`can(has_unknown)`,
163+
map[string]cty.Value{
164+
"has_unknown": cty.ListVal([]cty.Value{cty.UnknownVal(cty.Bool)}),
165+
},
166+
cty.UnknownVal(cty.Bool),
167+
},
168+
}
169+
170+
for k, test := range tests {
171+
t.Run(k, func(t *testing.T) {
172+
expr, diags := hclsyntax.ParseExpression([]byte(test.expr), "test.hcl", hcl.Pos{Line: 1, Column: 1})
173+
if diags.HasErrors() {
174+
t.Fatalf("unexpected problems: %s", diags.Error())
175+
}
176+
177+
ctx := &hcl.EvalContext{
178+
Variables: test.vars,
179+
Functions: map[string]function.Function{
180+
"can": CanFunc,
181+
},
182+
}
183+
184+
got, err := expr.Value(ctx)
185+
if err != nil {
186+
t.Errorf("unexpected error\ngot: %s\nwant: <nil>", err)
187+
}
188+
if !test.want.RawEquals(got) {
189+
t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.want)
190+
}
191+
})
192+
}
193+
}

0 commit comments

Comments
 (0)