From 55b607ac3085ec395b03d249111dc692ff028dee Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Sat, 14 Dec 2019 12:45:31 -0800 Subject: [PATCH] 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. --- ext/tryfunc/README.md | 44 ++++++++ ext/tryfunc/tryfunc.go | 150 ++++++++++++++++++++++++++++ ext/tryfunc/tryfunc_test.go | 193 ++++++++++++++++++++++++++++++++++++ 3 files changed, 387 insertions(+) create mode 100644 ext/tryfunc/README.md create mode 100644 ext/tryfunc/tryfunc.go create mode 100644 ext/tryfunc/tryfunc_test.go diff --git a/ext/tryfunc/README.md b/ext/tryfunc/README.md new file mode 100644 index 0000000..5d56eec --- /dev/null +++ b/ext/tryfunc/README.md @@ -0,0 +1,44 @@ +# "Try" and "can" functions + +This Go package contains two `cty` functions intended for use in an +`hcl.EvalContext` when evaluating HCL native syntax expressions. + +The first function `try` attempts to evaluate each of its argument expressions +in order until one produces a result without any errors. + +```hcl +try(non_existent_variable, 2) # returns 2 +``` + +If none of the expressions succeed, the function call fails with all of the +errors it encountered. + +The second function `can` is similar except that it ignores the result of +the given expression altogether and simply returns `true` if the expression +produced a successful result or `false` if it produced errors. + +Both of these are primarily intended for working with deep data structures +which might not have a dependable shape. For example, we can use `try` to +attempt to fetch a value from deep inside a data structure but produce a +default value if any step of the traversal fails: + +```hcl +result = try(foo.deep[0].lots.of["traversals"], null) +``` + +The final result to `try` should generally be some sort of constant value that +will always evaluate successfully. + +## Using these functions + +Languages built on HCL can make `try` and `can` available to user code by +exporting them in the `hcl.EvalContext` used for expression evaluation: + +```go +ctx := &hcl.EvalContext{ + Functions: map[string]function.Function{ + "try": tryfunc.TryFunc, + "can": tryfunc.CanFunc, + }, +} +``` diff --git a/ext/tryfunc/tryfunc.go b/ext/tryfunc/tryfunc.go new file mode 100644 index 0000000..2f4862f --- /dev/null +++ b/ext/tryfunc/tryfunc.go @@ -0,0 +1,150 @@ +// Package tryfunc contains some optional functions that can be exposed in +// HCL-based languages to allow authors to test whether a particular expression +// can succeed and take dynamic action based on that result. +// +// These functions are implemented in terms of the customdecode extension from +// the sibling directory "customdecode", and so they are only useful when +// used within an HCL EvalContext. Other systems using cty functions are +// unlikely to support the HCL-specific "customdecode" extension. +package tryfunc + +import ( + "errors" + "fmt" + "strings" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/ext/customdecode" + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/function" +) + +// TryFunc is a variadic function that tries to evaluate all of is arguments +// in sequence until one succeeds, in which case it returns that result, or +// returns an error if none of them succeed. +var TryFunc function.Function + +// CanFunc tries to evaluate the expression given in its first argument. +var CanFunc function.Function + +func init() { + TryFunc = function.New(&function.Spec{ + VarParam: &function.Parameter{ + Name: "expressions", + Type: customdecode.ExpressionClosureType, + }, + Type: func(args []cty.Value) (cty.Type, error) { + v, err := try(args) + if err != nil { + return cty.NilType, err + } + return v.Type(), nil + }, + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + return try(args) + }, + }) + CanFunc = function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "expression", + Type: customdecode.ExpressionClosureType, + }, + }, + Type: function.StaticReturnType(cty.Bool), + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + return can(args[0]) + }, + }) +} + +func try(args []cty.Value) (cty.Value, error) { + if len(args) == 0 { + return cty.NilVal, errors.New("at least one argument is required") + } + + // We'll collect up all of the diagnostics we encounter along the way + // and report them all if none of the expressions succeed, so that the + // user might get some hints on how to make at least one succeed. + var diags hcl.Diagnostics + for _, arg := range args { + closure := customdecode.ExpressionClosureFromVal(arg) + if dependsOnUnknowns(closure.Expression, closure.EvalContext) { + // We can't safely decide if this expression will succeed yet, + // and so our entire result must be unknown until we have + // more information. + return cty.DynamicVal, nil + } + + v, moreDiags := closure.Value() + diags = append(diags, moreDiags...) + if moreDiags.HasErrors() { + continue // try the next one, if there is one to try + } + return v, nil // ignore any accumulated diagnostics if one succeeds + } + + // If we fall out here then none of the expressions succeeded, and so + // we must have at least one diagnostic and we'll return all of them + // so that the user can see the errors related to whichever one they + // were expecting to have succeeded in this case. + // + // Because our function must return a single error value rather than + // diagnostics, we'll construct a suitable error message string + // that will make sense in the context of the function call failure + // diagnostic HCL will eventually wrap this in. + var buf strings.Builder + buf.WriteString("no expression succeeded:\n") + for _, diag := range diags { + if diag.Subject != nil { + buf.WriteString(fmt.Sprintf("- %s (at %s)\n %s\n", diag.Summary, diag.Subject, diag.Detail)) + } else { + buf.WriteString(fmt.Sprintf("- %s\n %s\n", diag.Summary, diag.Detail)) + } + } + buf.WriteString("\nAt least one expression must produce a successful result") + return cty.NilVal, errors.New(buf.String()) +} + +func can(arg cty.Value) (cty.Value, error) { + closure := customdecode.ExpressionClosureFromVal(arg) + if dependsOnUnknowns(closure.Expression, closure.EvalContext) { + // Can't decide yet, then. + return cty.UnknownVal(cty.Bool), nil + } + + _, diags := closure.Value() + if diags.HasErrors() { + return cty.False, nil + } + return cty.True, nil +} + +// dependsOnUnknowns returns true if any of the variables that the given +// expression might access are unknown values or contain unknown values. +// +// This is a conservative result that prefers to return true if there's any +// chance that the expression might derive from an unknown value during its +// evaluation; it is likely to produce false-positives for more complex +// expressions involving deep data structures. +func dependsOnUnknowns(expr hcl.Expression, ctx *hcl.EvalContext) bool { + for _, traversal := range expr.Variables() { + val, diags := traversal.TraverseAbs(ctx) + if diags.HasErrors() { + // If the traversal returned a definitive error then it must + // not traverse through any unknowns. + continue + } + if !val.IsWhollyKnown() { + // The value will be unknown if either it refers directly to + // an unknown value or if the traversal moves through an unknown + // collection. We're using IsWhollyKnown, so this also catches + // situations where the traversal refers to a compound data + // structure that contains any unknown values. That's important, + // because during evaluation the expression might evaluate more + // deeply into this structure and encounter the unknowns. + return true + } + } + return false +} diff --git a/ext/tryfunc/tryfunc_test.go b/ext/tryfunc/tryfunc_test.go new file mode 100644 index 0000000..063adab --- /dev/null +++ b/ext/tryfunc/tryfunc_test.go @@ -0,0 +1,193 @@ +package tryfunc + +import ( + "testing" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/function" +) + +func TestTryFunc(t *testing.T) { + tests := map[string]struct { + expr string + vars map[string]cty.Value + want cty.Value + wantErr string + }{ + "one argument succeeds": { + `try(1)`, + nil, + cty.NumberIntVal(1), + ``, + }, + "two arguments, first succeeds": { + `try(1, 2)`, + nil, + cty.NumberIntVal(1), + ``, + }, + "two arguments, first fails": { + `try(nope, 2)`, + nil, + cty.NumberIntVal(2), + ``, + }, + "two arguments, first depends on unknowns": { + `try(unknown, 2)`, + map[string]cty.Value{ + "unknown": cty.UnknownVal(cty.Number), + }, + cty.DynamicVal, // can't proceed until first argument is known + ``, + }, + "two arguments, first succeeds and second depends on unknowns": { + `try(1, unknown)`, + map[string]cty.Value{ + "unknown": cty.UnknownVal(cty.Number), + }, + cty.NumberIntVal(1), // we know 1st succeeds, so it doesn't matter that 2nd is unknown + ``, + }, + "two arguments, first depends on unknowns deeply": { + `try(has_unknowns, 2)`, + map[string]cty.Value{ + "has_unknowns": cty.ListVal([]cty.Value{cty.UnknownVal(cty.Bool)}), + }, + cty.DynamicVal, // can't proceed until first argument is wholly known + ``, + }, + "two arguments, first traverses through an unkown": { + `try(unknown.baz, 2)`, + map[string]cty.Value{ + "unknown": cty.UnknownVal(cty.Map(cty.String)), + }, + cty.DynamicVal, // can't proceed until first argument is wholly known + ``, + }, + "three arguments, all fail": { + `try(this, that, this_thing_in_particular)`, + nil, + cty.NumberIntVal(2), + // The grammar of this stringification of the message is unfortunate, + // but caller can type-assert our result to get the original + // diagnostics directly in order to produce a better result. + `test.hcl:1,1-5: Error in function call; Call to function "try" failed: no expression succeeded: +- Variables not allowed (at test.hcl:1,5-9) + Variables may not be used here. +- Variables not allowed (at test.hcl:1,11-15) + Variables may not be used here. +- Variables not allowed (at test.hcl:1,17-41) + Variables may not be used here. + +At least one expression must produce a successful result.`, + }, + "no arguments": { + `try()`, + nil, + cty.NilVal, + `test.hcl:1,1-5: Error in function call; Call to function "try" failed: at least one argument is required.`, + }, + } + + for k, test := range tests { + t.Run(k, func(t *testing.T) { + expr, diags := hclsyntax.ParseExpression([]byte(test.expr), "test.hcl", hcl.Pos{Line: 1, Column: 1}) + if diags.HasErrors() { + t.Fatalf("unexpected problems: %s", diags.Error()) + } + + ctx := &hcl.EvalContext{ + Variables: test.vars, + Functions: map[string]function.Function{ + "try": TryFunc, + }, + } + + got, err := expr.Value(ctx) + + if err != nil { + if test.wantErr != "" { + if got, want := err.Error(), test.wantErr; got != want { + t.Errorf("wrong error\ngot: %s\nwant: %s", got, want) + } + } else { + t.Errorf("unexpected error\ngot: %s\nwant: ", err) + } + return + } + if test.wantErr != "" { + t.Errorf("wrong error\ngot: \nwant: %s", test.wantErr) + } + + if !test.want.RawEquals(got) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.want) + } + }) + } +} + +func TestCanFunc(t *testing.T) { + tests := map[string]struct { + expr string + vars map[string]cty.Value + want cty.Value + }{ + "succeeds": { + `can(1)`, + nil, + cty.True, + }, + "fails": { + `can(nope)`, + nil, + cty.False, + }, + "simple unknown": { + `can(unknown)`, + map[string]cty.Value{ + "unknown": cty.UnknownVal(cty.Number), + }, + cty.UnknownVal(cty.Bool), + }, + "traversal through unknown": { + `can(unknown.foo)`, + map[string]cty.Value{ + "unknown": cty.UnknownVal(cty.Map(cty.Number)), + }, + cty.UnknownVal(cty.Bool), + }, + "deep unknown": { + `can(has_unknown)`, + map[string]cty.Value{ + "has_unknown": cty.ListVal([]cty.Value{cty.UnknownVal(cty.Bool)}), + }, + cty.UnknownVal(cty.Bool), + }, + } + + for k, test := range tests { + t.Run(k, func(t *testing.T) { + expr, diags := hclsyntax.ParseExpression([]byte(test.expr), "test.hcl", hcl.Pos{Line: 1, Column: 1}) + if diags.HasErrors() { + t.Fatalf("unexpected problems: %s", diags.Error()) + } + + ctx := &hcl.EvalContext{ + Variables: test.vars, + Functions: map[string]function.Function{ + "can": CanFunc, + }, + } + + got, err := expr.Value(ctx) + if err != nil { + t.Errorf("unexpected error\ngot: %s\nwant: ", err) + } + if !test.want.RawEquals(got) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.want) + } + }) + } +}