From d8ae04bc78d34171ff8a8e14c1fb8158ff4a77d7 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Fri, 13 Dec 2019 08:52:25 -0800 Subject: ext/customdecode: Custom expression decoding extension Most of the time, the standard expression decoding built in to HCL is sufficient. Sometimes though, it's useful to be able to customize the decoding of certain arguments where the application intends to use them in a very specific way, such as in static analysis. This extension is an approximate analog of gohcl's support for decoding into an hcl.Expression, allowing hcldec-based applications and applications with custom functions to similarly capture and manipulate the physical expressions used in arguments, rather than their values. This includes one example use-case: the typeexpr extension now includes a cty.Function called ConvertFunc that takes a type expression as its second argument. A type expression is not evaluatable in the usual sense, but thanks to cty capsule types we _can_ produce a cty.Value from one and then make use of it inside the function implementation, without exposing this custom type to the broader language: convert(["foo"], set(string)) This mechanism is intentionally restricted only to "argument-like" locations where there is a specific type we are attempting to decode into. For now, that's hcldec AttrSpec/BlockAttrsSpec -- analogous to gohcl decoding into hcl.Expression -- and in arguments to functions. --- ext/typeexpr/README.md | 68 ++++++++++++++++++++++++ ext/typeexpr/type_type.go | 118 +++++++++++++++++++++++++++++++++++++++++ ext/typeexpr/type_type_test.go | 118 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 304 insertions(+) create mode 100644 ext/typeexpr/type_type.go create mode 100644 ext/typeexpr/type_type_test.go (limited to 'ext/typeexpr') diff --git a/ext/typeexpr/README.md b/ext/typeexpr/README.md index ec70947..058f1e3 100644 --- a/ext/typeexpr/README.md +++ b/ext/typeexpr/README.md @@ -65,3 +65,71 @@ type checking it will be one that has identifiers as its attributes; object types with weird attributes generally show up only from arbitrary object constructors in configuration files, which are usually treated either as maps or as the dynamic pseudo-type. + +## Type Constraints as Values + +Along with defining a convention for writing down types using HCL expression +constructs, this package also includes a mechanism for representing types as +values that can be used as data within an HCL-based language. + +`typeexpr.TypeConstraintType` is a +[`cty` capsule type](https://github.com/zclconf/go-cty/blob/master/docs/types.md#capsule-types) +that encapsulates `cty.Type` values. You can construct such a value directly +using the `TypeConstraintVal` function: + +```go +tyVal := typeexpr.TypeConstraintVal(cty.String) + +// We can unpack the type from a value using TypeConstraintFromVal +ty := typeExpr.TypeConstraintFromVal(tyVal) +``` + +However, the primary purpose of `typeexpr.TypeConstraintType` is to be +specified as the type constraint for an argument, in which case it serves +as a signal for HCL to treat the argument expression as a type constraint +expression as defined above, rather than as a normal value expression. + +"An argument" in the above in practice means the following two locations: + +* As the type constraint for a parameter of a cty function that will be + used in an `hcl.EvalContext`. In that case, function calls in the HCL + native expression syntax will require the argument to be valid type constraint + expression syntax and the function implementation will receive a + `TypeConstraintType` value as the argument value for that parameter. + +* As the type constraint for a `hcldec.AttrSpec` or `hcldec.BlockAttrsSpec` + when decoding an HCL body using `hcldec`. In that case, the attributes + with that type constraint will be required to be valid type constraint + expression syntax and the result will be a `TypeConstraintType` value. + +Note that the special handling of these arguments means that an argument +marked in this way must use the type constraint syntax directly. It is not +valid to pass in a value of `TypeConstraintType` that has been obtained +dynamically via some other expression result. + +`TypeConstraintType` is provided with the intent of using it internally within +application code when incorporating type constraint expression syntax into +an HCL-based language, not to be used for dynamic "programming with types". A +calling application could support programming with types by defining its _own_ +capsule type, but that is not the purpose of `TypeConstraintType`. + +## The "convert" `cty` Function + +Building on the `TypeConstraintType` described in the previous section, this +package also provides `typeexpr.ConvertFunc` which is a cty function that +can be placed into a `cty.EvalContext` (conventionally named "convert") in +order to provide a general type conversion function in an HCL-based language: + +```hcl + foo = convert("true", bool) +``` + +The second parameter uses the mechanism described in the previous section to +require its argument to be a type constraint expression rather than a value +expression. In doing so, it allows converting with any type constraint that +can be expressed in this package's type constraint syntax. In the above example, +the `foo` argument would receive a boolean true, or `cty.True` in `cty` terms. + +The target type constraint must always be provided statically using inline +type constraint syntax. There is no way to _dynamically_ select a type +constraint using this function. diff --git a/ext/typeexpr/type_type.go b/ext/typeexpr/type_type.go new file mode 100644 index 0000000..5462d82 --- /dev/null +++ b/ext/typeexpr/type_type.go @@ -0,0 +1,118 @@ +package typeexpr + +import ( + "fmt" + "reflect" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/ext/customdecode" + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/convert" + "github.com/zclconf/go-cty/cty/function" +) + +// TypeConstraintType is a cty capsule type that allows cty type constraints to +// be used as values. +// +// If TypeConstraintType is used in a context supporting the +// customdecode.CustomExpressionDecoder extension then it will implement +// expression decoding using the TypeConstraint function, thus allowing +// type expressions to be used in contexts where value expressions might +// normally be expected, such as in arguments to function calls. +var TypeConstraintType cty.Type + +// TypeConstraintVal constructs a cty.Value whose type is +// TypeConstraintType. +func TypeConstraintVal(ty cty.Type) cty.Value { + return cty.CapsuleVal(TypeConstraintType, &ty) +} + +// TypeConstraintFromVal extracts the type from a cty.Value of +// TypeConstraintType that was previously constructed using TypeConstraintVal. +// +// If the given value isn't a known, non-null value of TypeConstraintType +// then this function will panic. +func TypeConstraintFromVal(v cty.Value) cty.Type { + if !v.Type().Equals(TypeConstraintType) { + panic("value is not of TypeConstraintType") + } + ptr := v.EncapsulatedValue().(*cty.Type) + return *ptr +} + +// ConvertFunc is a cty function that implements type conversions. +// +// Its signature is as follows: +// convert(value, type_constraint) +// +// ...where type_constraint is a type constraint expression as defined by +// typeexpr.TypeConstraint. +// +// It relies on HCL's customdecode extension and so it's not suitable for use +// in non-HCL contexts or if you are using a HCL syntax implementation that +// does not support customdecode for function arguments. However, it _is_ +// supported for function calls in the HCL native expression syntax. +var ConvertFunc function.Function + +func init() { + TypeConstraintType = cty.CapsuleWithOps("type constraint", reflect.TypeOf(cty.Type{}), &cty.CapsuleOps{ + ExtensionData: func(key interface{}) interface{} { + switch key { + case customdecode.CustomExpressionDecoder: + return customdecode.CustomExpressionDecoderFunc( + func(expr hcl.Expression, ctx *hcl.EvalContext) (cty.Value, hcl.Diagnostics) { + ty, diags := TypeConstraint(expr) + if diags.HasErrors() { + return cty.NilVal, diags + } + return TypeConstraintVal(ty), nil + }, + ) + default: + return nil + } + }, + TypeGoString: func(_ reflect.Type) string { + return "typeexpr.TypeConstraintType" + }, + GoString: func(raw interface{}) string { + tyPtr := raw.(*cty.Type) + return fmt.Sprintf("typeexpr.TypeConstraintVal(%#v)", *tyPtr) + }, + RawEquals: func(a, b interface{}) bool { + aPtr := a.(*cty.Type) + bPtr := b.(*cty.Type) + return (*aPtr).Equals(*bPtr) + }, + }) + + ConvertFunc = function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "value", + Type: cty.DynamicPseudoType, + AllowNull: true, + AllowDynamicType: true, + }, + { + Name: "type", + Type: TypeConstraintType, + }, + }, + Type: func(args []cty.Value) (cty.Type, error) { + wantTypePtr := args[1].EncapsulatedValue().(*cty.Type) + got, err := convert.Convert(args[0], *wantTypePtr) + if err != nil { + return cty.NilType, function.NewArgError(0, err) + } + return got.Type(), nil + }, + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + v, err := convert.Convert(args[0], retType) + if err != nil { + return cty.NilVal, function.NewArgError(0, err) + } + return v, nil + }, + }) +} diff --git a/ext/typeexpr/type_type_test.go b/ext/typeexpr/type_type_test.go new file mode 100644 index 0000000..2286a2e --- /dev/null +++ b/ext/typeexpr/type_type_test.go @@ -0,0 +1,118 @@ +package typeexpr + +import ( + "fmt" + "testing" + + "github.com/zclconf/go-cty/cty" +) + +func TestTypeConstraintType(t *testing.T) { + tyVal1 := TypeConstraintVal(cty.String) + tyVal2 := TypeConstraintVal(cty.String) + tyVal3 := TypeConstraintVal(cty.Number) + + if !tyVal1.RawEquals(tyVal2) { + t.Errorf("tyVal1 not equal to tyVal2\ntyVal1: %#v\ntyVal2: %#v", tyVal1, tyVal2) + } + if tyVal1.RawEquals(tyVal3) { + t.Errorf("tyVal1 equal to tyVal2, but should not be\ntyVal1: %#v\ntyVal3: %#v", tyVal1, tyVal3) + } + + if got, want := TypeConstraintFromVal(tyVal1), cty.String; !got.Equals(want) { + t.Errorf("wrong type extracted from tyVal1\ngot: %#v\nwant: %#v", got, want) + } + if got, want := TypeConstraintFromVal(tyVal3), cty.Number; !got.Equals(want) { + t.Errorf("wrong type extracted from tyVal3\ngot: %#v\nwant: %#v", got, want) + } +} + +func TestConvertFunc(t *testing.T) { + // This is testing the convert function directly, skipping over the HCL + // parsing and evaluation steps that would normally lead there. There is + // another test in the "integrationtest" package called TestTypeConvertFunc + // that exercises the full path to this function via the hclsyntax parser. + + tests := []struct { + val, ty cty.Value + want cty.Value + wantErr string + }{ + // The goal here is not an exhaustive set of conversions, since that's + // already covered in cty/convert, but rather exercising different + // permutations of success and failure to make sure the function + // handles all of the results in a reasonable way. + { + cty.StringVal("hello"), + TypeConstraintVal(cty.String), + cty.StringVal("hello"), + ``, + }, + { + cty.True, + TypeConstraintVal(cty.String), + cty.StringVal("true"), + ``, + }, + { + cty.StringVal("hello"), + TypeConstraintVal(cty.Bool), + cty.NilVal, + `a bool is required`, + }, + { + cty.UnknownVal(cty.Bool), + TypeConstraintVal(cty.Bool), + cty.UnknownVal(cty.Bool), + ``, + }, + { + cty.DynamicVal, + TypeConstraintVal(cty.Bool), + cty.UnknownVal(cty.Bool), + ``, + }, + { + cty.NullVal(cty.Bool), + TypeConstraintVal(cty.Bool), + cty.NullVal(cty.Bool), + ``, + }, + { + cty.NullVal(cty.DynamicPseudoType), + TypeConstraintVal(cty.Bool), + cty.NullVal(cty.Bool), + ``, + }, + { + cty.StringVal("hello").Mark(1), + TypeConstraintVal(cty.String), + cty.StringVal("hello").Mark(1), + ``, + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("%#v to %#v", test.val, test.ty), func(t *testing.T) { + got, err := ConvertFunc.Call([]cty.Value{test.val, test.ty}) + + 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) + } + }) + } +} -- cgit v1.2.3