From 2b622fe31a6828070bd5260ce7c43917731eed2c Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Tue, 3 Oct 2017 17:16:04 -0700 Subject: [PATCH] config/configschema: Block.DecoderSpec This returns a decoding specification that can be used with the hcldec package to decode a body into a cty.Value of an object type. --- config/configschema/decoder_spec.go | 91 ++++++- config/configschema/decoder_spec_test.go | 301 +++++++++++++++++++++++ 2 files changed, 389 insertions(+), 3 deletions(-) create mode 100644 config/configschema/decoder_spec_test.go diff --git a/config/configschema/decoder_spec.go b/config/configschema/decoder_spec.go index 8b592a3ea..f18e06b5d 100644 --- a/config/configschema/decoder_spec.go +++ b/config/configschema/decoder_spec.go @@ -2,15 +2,100 @@ package configschema import ( "github.com/hashicorp/hcl2/hcldec" + "github.com/zclconf/go-cty/cty" ) -// DecoderSpec returns a zcldec.Spec that can be used to decode a zcl Body -// using the facilities in the zcldec package. +var mapLabelNames = []string{"key"} + +// DecoderSpec returns a hcldec.Spec that can be used to decode a HCL Body +// using the facilities in the hcldec package. // // The returned specification is guaranteed to return a value of the same type // returned by method ImpliedType, but it may contain null or unknown values if // any of the block attributes are defined as optional and/or computed // respectively. func (b *Block) DecoderSpec() hcldec.Spec { - return nil + ret := hcldec.ObjectSpec{} + if b == nil { + return ret + } + + // If the behavior here is changed, usually the behavior of ImpliedType + // must be changed to match. It is required that this method produce + // a specification that decodes into a value of the implied type. + + for name, attrS := range b.Attributes { + switch { + case attrS.Computed && attrS.Optional: + // In this special case we use an unknown value as a default + // to get the intended behavior that the result is computed + // unless it has been explicitly set in config. + ret[name] = &hcldec.DefaultSpec{ + Primary: &hcldec.AttrSpec{ + Name: name, + Type: attrS.Type, + }, + Default: &hcldec.LiteralSpec{ + Value: cty.UnknownVal(attrS.Type), + }, + } + case attrS.Computed: + ret[name] = &hcldec.LiteralSpec{ + Value: cty.UnknownVal(attrS.Type), + } + default: + ret[name] = &hcldec.AttrSpec{ + Name: name, + Type: attrS.Type, + Required: attrS.Required, + } + } + } + + for name, blockS := range b.BlockTypes { + if _, exists := ret[name]; exists { + // This indicates an invalid schema, since it's not valid to + // define both an attribute and a block type of the same name. + // However, we don't raise this here since it's checked by + // InternalValidate. + continue + } + + childSpec := blockS.Block.DecoderSpec() + + switch blockS.Nesting { + case NestingSingle: + ret[name] = &hcldec.BlockSpec{ + TypeName: name, + Nested: childSpec, + Required: blockS.MinItems == 1 && blockS.MaxItems >= 1, + } + case NestingList: + ret[name] = &hcldec.BlockListSpec{ + TypeName: name, + Nested: childSpec, + MinItems: blockS.MinItems, + MaxItems: blockS.MaxItems, + } + case NestingSet: + ret[name] = &hcldec.BlockSetSpec{ + TypeName: name, + Nested: childSpec, + MinItems: blockS.MinItems, + MaxItems: blockS.MaxItems, + } + case NestingMap: + ret[name] = &hcldec.BlockMapSpec{ + TypeName: name, + Nested: childSpec, + LabelNames: mapLabelNames, + } + default: + // Invalid nesting type is just ignored. It's checked by + // InternalValidate. + continue + } + } + + return ret } diff --git a/config/configschema/decoder_spec_test.go b/config/configschema/decoder_spec_test.go new file mode 100644 index 000000000..50cfbecf0 --- /dev/null +++ b/config/configschema/decoder_spec_test.go @@ -0,0 +1,301 @@ +package configschema + +import ( + "testing" + + "github.com/davecgh/go-spew/spew" + + "github.com/hashicorp/hcl2/hcl" + "github.com/hashicorp/hcl2/hcldec" + "github.com/hashicorp/hcl2/hcltest" + "github.com/zclconf/go-cty/cty" +) + +func TestBlockDecoderSpec(t *testing.T) { + tests := map[string]struct { + Schema *Block + TestBody hcl.Body + Want cty.Value + DiagCount int + }{ + "empty": { + &Block{}, + hcl.EmptyBody(), + cty.EmptyObjectVal, + 0, + }, + "nil": { + nil, + hcl.EmptyBody(), + cty.EmptyObjectVal, + 0, + }, + "attributes": { + &Block{ + Attributes: map[string]*Attribute{ + "optional": { + Type: cty.Number, + Optional: true, + }, + "required": { + Type: cty.String, + Required: true, + }, + "computed": { + Type: cty.List(cty.Bool), + Computed: true, + }, + "optional_computed": { + Type: cty.Map(cty.Bool), + Optional: true, + Computed: true, + }, + "optional_computed_overridden": { + Type: cty.Bool, + Optional: true, + Computed: true, + }, + }, + }, + hcltest.MockBody(&hcl.BodyContent{ + Attributes: hcl.Attributes{ + "required": { + Name: "required", + Expr: hcltest.MockExprLiteral(cty.NumberIntVal(5)), + }, + "optional_computed_overridden": { + Name: "optional_computed_overridden", + Expr: hcltest.MockExprLiteral(cty.True), + }, + }, + }), + cty.ObjectVal(map[string]cty.Value{ + "optional": cty.NullVal(cty.Number), + "required": cty.StringVal("5"), // converted from number to string + "computed": cty.UnknownVal(cty.List(cty.Bool)), + "optional_computed": cty.UnknownVal(cty.Map(cty.Bool)), + "optional_computed_overridden": cty.True, + }), + 0, + }, + "dynamically-typed attribute": { + &Block{ + Attributes: map[string]*Attribute{ + "foo": { + Type: cty.DynamicPseudoType, // any type is permitted + Required: true, + }, + }, + }, + hcltest.MockBody(&hcl.BodyContent{ + Attributes: hcl.Attributes{ + "foo": { + Name: "foo", + Expr: hcltest.MockExprLiteral(cty.True), + }, + }, + }), + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.True, + }), + 0, + }, + "dynamically-typed attribute omitted": { + &Block{ + Attributes: map[string]*Attribute{ + "foo": { + Type: cty.DynamicPseudoType, // any type is permitted + Optional: true, + }, + }, + }, + hcltest.MockBody(&hcl.BodyContent{}), + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.NullVal(cty.DynamicPseudoType), + }), + 0, + }, + "required attribute omitted": { + &Block{ + Attributes: map[string]*Attribute{ + "foo": { + Type: cty.Bool, + Required: true, + }, + }, + }, + hcltest.MockBody(&hcl.BodyContent{}), + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.NullVal(cty.Bool), + }), + 1, // missing required attribute + }, + "wrong attribute type": { + &Block{ + Attributes: map[string]*Attribute{ + "optional": { + Type: cty.Number, + Optional: true, + }, + }, + }, + hcltest.MockBody(&hcl.BodyContent{ + Attributes: hcl.Attributes{ + "optional": { + Name: "optional", + Expr: hcltest.MockExprLiteral(cty.True), + }, + }, + }), + cty.ObjectVal(map[string]cty.Value{ + "optional": cty.UnknownVal(cty.Number), + }), + 1, // incorrect type; number required + }, + "blocks": { + &Block{ + BlockTypes: map[string]*NestedBlock{ + "single": { + Nesting: NestingSingle, + Block: Block{}, + }, + "list": { + Nesting: NestingList, + Block: Block{}, + }, + "set": { + Nesting: NestingSet, + Block: Block{}, + }, + "map": { + Nesting: NestingMap, + Block: Block{}, + }, + }, + }, + hcltest.MockBody(&hcl.BodyContent{ + Blocks: hcl.Blocks{ + &hcl.Block{ + Type: "list", + Body: hcl.EmptyBody(), + }, + &hcl.Block{ + Type: "single", + Body: hcl.EmptyBody(), + }, + &hcl.Block{ + Type: "list", + Body: hcl.EmptyBody(), + }, + &hcl.Block{ + Type: "set", + Body: hcl.EmptyBody(), + }, + &hcl.Block{ + Type: "map", + Labels: []string{"foo"}, + LabelRanges: []hcl.Range{hcl.Range{}}, + Body: hcl.EmptyBody(), + }, + &hcl.Block{ + Type: "map", + Labels: []string{"bar"}, + LabelRanges: []hcl.Range{hcl.Range{}}, + Body: hcl.EmptyBody(), + }, + &hcl.Block{ + Type: "set", + Body: hcl.EmptyBody(), + }, + }, + }), + cty.ObjectVal(map[string]cty.Value{ + "single": cty.EmptyObjectVal, + "list": cty.ListVal([]cty.Value{ + cty.EmptyObjectVal, + cty.EmptyObjectVal, + }), + "set": cty.SetVal([]cty.Value{ + cty.EmptyObjectVal, + cty.EmptyObjectVal, + }), + "map": cty.MapVal(map[string]cty.Value{ + "foo": cty.EmptyObjectVal, + "bar": cty.EmptyObjectVal, + }), + }), + 0, + }, + "too many list items": { + &Block{ + BlockTypes: map[string]*NestedBlock{ + "foo": { + Nesting: NestingList, + Block: Block{}, + MaxItems: 1, + }, + }, + }, + hcltest.MockBody(&hcl.BodyContent{ + Blocks: hcl.Blocks{ + &hcl.Block{ + Type: "foo", + Body: hcl.EmptyBody(), + }, + &hcl.Block{ + Type: "foo", + Body: hcl.EmptyBody(), + }, + }, + }), + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.ListVal([]cty.Value{ + cty.EmptyObjectVal, + cty.EmptyObjectVal, + }), + }), + 1, // too many "foo" blocks + }, + "extraneous attribute": { + &Block{}, + hcltest.MockBody(&hcl.BodyContent{ + Attributes: hcl.Attributes{ + "extra": { + Name: "extra", + Expr: hcltest.MockExprLiteral(cty.StringVal("hello")), + }, + }, + }), + cty.EmptyObjectVal, + 1, // extraneous attribute + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + spec := test.Schema.DecoderSpec() + got, diags := hcldec.Decode(test.TestBody, spec, nil) + if len(diags) != test.DiagCount { + t.Errorf("wrong number of diagnostics %d; want %d", len(diags), test.DiagCount) + for _, diag := range diags { + t.Logf("- %s", diag.Error()) + } + } + + if !got.RawEquals(test.Want) { + t.Logf("[INFO] implied schema is %s", spew.Sdump(hcldec.ImpliedSchema(spec))) + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.Want) + } + + // Double-check that we're producing consistent results for DecoderSpec + // and ImpliedType. + impliedType := test.Schema.ImpliedType() + if errs := got.Type().TestConformance(impliedType); len(errs) != 0 { + t.Errorf("result does not conform to the schema's implied type") + for _, err := range errs { + t.Logf("- %s", err.Error()) + } + } + }) + } +}