From 881ccfb8ad00ed87f7af95ab9cc32fa83443295d Mon Sep 17 00:00:00 2001 From: Ronnie Flathers Date: Wed, 10 May 2023 12:17:56 -0500 Subject: [PATCH] add interfaces and tests --- gohcl/encode.go | 50 +++++++++++--- gohcl/encode_test.go | 156 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 194 insertions(+), 12 deletions(-) diff --git a/gohcl/encode.go b/gohcl/encode.go index 64cb2d1e..0e23a3bc 100644 --- a/gohcl/encode.go +++ b/gohcl/encode.go @@ -12,6 +12,18 @@ import ( "github.com/zclconf/go-cty/cty/gocty" ) +// ExpressionMarshaler defines an interface for marshaling a field type as an HCL expression. +type ExpressionMarshaler interface { + MarshalExpression() (*hclwrite.Expression, error) +} + +// BlockMarshaler defines an interface for marshalling a type to an *hclwrite.Block +// It can be used to implement custom hcl block writing for structs and types, including +// when used as fields with the block tag +type BlockMarshaler interface { + MarshalBlock(blockType string) *hclwrite.Block +} + // EncodeIntoBody replaces the contents of the given hclwrite Body with // attributes and blocks derived from the given value, which must be a // struct value or a pointer to a struct value with the struct tags defined @@ -62,6 +74,9 @@ func EncodeIntoBody(val interface{}, dst *hclwrite.Body) { // if they are violated. func EncodeAsBlock(val interface{}, blockType string) *hclwrite.Block { rv := reflect.ValueOf(val) + if blockMarshaler, ok := rv.Interface().(BlockMarshaler); ok { + return blockMarshaler.MarshalBlock(blockType) + } ty := rv.Type() if ty.Kind() == reflect.Ptr { rv = rv.Elem() @@ -131,19 +146,32 @@ func populateBody(rv reflect.Value, ty reflect.Type, tags *fieldTags, dst *hclwr prevWasBlock = false } - valTy, err := gocty.ImpliedType(fieldVal.Interface()) - if err != nil { - panic(fmt.Sprintf("cannot encode %T as HCL expression: %s", fieldVal.Interface(), err)) - } + // Check if type is an ExpressionMarshaler and use its interface method + exprMarshaler, ok := fieldVal.Interface().(ExpressionMarshaler) + if ok { + val, err := exprMarshaler.MarshalExpression() + if err != nil { + panic(fmt.Sprintf("cannot encode %T as HCL expression: %s", fieldVal.Interface(), err)) + } + var valTokens hclwrite.Tokens + valTokens = val.BuildTokens(valTokens) + dst.SetAttributeRaw(name, valTokens) + } else { + // use go-cty implied types and values + valTy, err := gocty.ImpliedType(fieldVal.Interface()) + if err != nil { + panic(fmt.Sprintf("cannot encode %T as HCL expression: %s", fieldVal.Interface(), err)) + } - val, err := gocty.ToCtyValue(fieldVal.Interface(), valTy) - if err != nil { - // This should never happen, since we should always be able - // to decode into the implied type. - panic(fmt.Sprintf("failed to encode %T as %#v: %s", fieldVal.Interface(), valTy, err)) - } + val, err := gocty.ToCtyValue(fieldVal.Interface(), valTy) + if err != nil { + // This should never happen, since we should always be able + // to decode into the implied type. + panic(fmt.Sprintf("failed to encode %T as %#v: %s", fieldVal.Interface(), valTy, err)) + } - dst.SetAttributeValue(name, val) + dst.SetAttributeValue(name, val) + } } else { // must be a block, then elemTy := fieldTy diff --git a/gohcl/encode_test.go b/gohcl/encode_test.go index a75bf28b..0ac6e76e 100644 --- a/gohcl/encode_test.go +++ b/gohcl/encode_test.go @@ -5,9 +5,10 @@ package gohcl_test import ( "fmt" - "github.com/hashicorp/hcl/v2/gohcl" "github.com/hashicorp/hcl/v2/hclwrite" + "github.com/zclconf/go-cty/cty" + "testing" ) func ExampleEncodeIntoBody() { @@ -65,3 +66,156 @@ func ExampleEncodeIntoBody() { // executable = ["./worker"] // } } + +// The following tests define a type alias and struct that implement ExpressionMarshaler and BlockMarshaler + +// rawString is a string that will be written literally to HCL without any escaping +type rawString string + +func (r rawString) MarshalExpression() (*hclwrite.Expression, error) { + return hclwrite.NewExpressionRaw(hclwrite.Tokens{ + { + Bytes: []byte(r), + }, + }), nil +} + +// customBlock implements BlockMarshalelr to hardcode a label and customize attribute name +type customBlock struct { + Name string +} + +func (c customBlock) MarshalBlock(blockType string) *hclwrite.Block { + block := hclwrite.NewBlock(blockType, []string{"hardcoded_label"}) + body := block.Body() + body.SetAttributeValue(fmt.Sprintf("name_%s", c.Name), cty.StringVal(c.Name)) + return block +} + +type testBlock struct { + Label string `hcl:",label"` + Title string `hcl:"title"` + RawExpr rawString `hcl:"raw"` + CustomBlock customBlock `hcl:"custom,block"` +} + +func TestMarshalInterfaces(t *testing.T) { + inBlock := testBlock{ + Label: "label1", + Title: "title", + RawExpr: "foo.bar[0]", + CustomBlock: customBlock{ + Name: "Foobar", + }, + } + f := hclwrite.NewEmptyFile() + gohcl.EncodeIntoBody(&inBlock, f.Body()) + + want := `title = "title" +raw = foo.bar[0] + +custom "hardcoded_label" { + name_Foobar = "Foobar" +} +` + got := fmt.Sprintf("%s", hclwrite.Format(f.Bytes())) + if got != want { + t.Errorf("got: %q, wanted, %q", got, want) + } +} + +// These tests are in place to ensure the interface checks for BlockMarshalers work with ptrs and concrete types and panic on nil and zero values +type Fruit interface { + isFruit() + gohcl.BlockMarshaler +} + +type Apple struct { + Name string `hcl:"name"` +} + +func (a Apple) isFruit() {} + +func (a Apple) MarshalBlock(blockType string) *hclwrite.Block { + type customBlock struct { + FruitName string `hcl:"fruit_name"` + } + return gohcl.EncodeAsBlock(customBlock{FruitName: "apple"}, blockType) +} + +func TestInterfaceBlocks(t *testing.T) { + type Banana struct { + Name string `hcl:"name"` + Fruit Fruit `hcl:"fruit,block"` + } + testCases := []struct { + name string + banana Banana + wantHcl string + wantPanic bool + }{ + { + name: "with concrete type", + banana: Banana{ + Name: "my banana", + Fruit: Apple{Name: "my-apple"}, + }, + wantHcl: `banana { + name = "my banana" + + fruit { + fruit_name = "apple" + } +} +`, + }, + { + name: "with ptr to struct", + banana: Banana{ + Name: "my banana", + Fruit: &Apple{Name: "my-apple-ptr"}, + }, + wantHcl: `banana { + name = "my banana" + + fruit { + fruit_name = "apple" + } +} +`, + }, + { + name: "with nil", + banana: Banana{ + Name: "my banana", + Fruit: nil, + }, + wantPanic: true, + }, + { + name: "with empty", + banana: Banana{ + Name: "my banana", + }, + wantPanic: true, + }, + } + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + defer func() { + if r := recover(); r != nil && !tt.wantPanic { + t.Errorf("got panic, did not want one") + } + }() + block := gohcl.EncodeAsBlock(tt.banana, "banana") + if block != nil { + f := hclwrite.NewEmptyFile() + f.Body().AppendBlock(block) + gotHcl := string(hclwrite.Format(f.Bytes())) + if gotHcl != tt.wantHcl { + t.Errorf("got: %q, want: %q", gotHcl, tt.wantHcl) + } + } + }) + } +}