configschema: Block.CoerceValue method

The usual way to use a configschema.Block is to obtain a hcldec spec from
it and then decode an hcl.Body. There are inevitably situations though
where a body has already been decoded into a cty.Value before we know
which schema we need to use.

This new method CoerceValue is intended to deal with this case, applying
the schema to an already-decoded value in what should be an intuitive way
for most situations.
This commit is contained in:
Martin Atkins 2018-03-16 19:47:59 -07:00
parent 6cf9346dfd
commit 22d140e883
2 changed files with 474 additions and 0 deletions

View File

@ -0,0 +1,241 @@
package configschema
import (
"fmt"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/convert"
)
// CoerceValue attempts to force the given value to conform to the type
// implied by the receiever, while also applying the same validation and
// transformation rules that would be applied by the decoder specification
// returned by method DecoderSpec.
//
// This is useful in situations where a configuration must be derived from
// an already-decoded value. It is always better to decode directly from
// configuration where possible since then source location information is
// still available to produce diagnostics, but in special situations this
// function allows a compatible result to be obtained even if the
// configuration objects are not available.
//
// If the given value cannot be converted to conform to the receiving schema
// then an error is returned describing one of possibly many problems. This
// error may be a cty.PathError indicating a position within the nested
// data structure where the problem applies.
func (b *Block) CoerceValue(in cty.Value) (cty.Value, error) {
var path cty.Path
return b.coerceValue(in, path)
}
func (b *Block) coerceValue(in cty.Value, path cty.Path) (cty.Value, error) {
switch {
case in.IsNull():
return cty.NullVal(b.ImpliedType()), nil
case !in.IsKnown():
return cty.UnknownVal(b.ImpliedType()), nil
}
ty := in.Type()
if !ty.IsObjectType() {
return cty.UnknownVal(b.ImpliedType()), path.NewErrorf("an object is required")
}
for name := range ty.AttributeTypes() {
if _, defined := b.Attributes[name]; defined {
continue
}
if _, defined := b.BlockTypes[name]; defined {
continue
}
return cty.UnknownVal(b.ImpliedType()), path.NewErrorf("unexpected attribute %q", name)
}
attrs := make(map[string]cty.Value)
for name, attrS := range b.Attributes {
var val cty.Value
switch {
case ty.HasAttribute(name):
val = in.GetAttr(name)
case attrS.Computed:
val = cty.UnknownVal(attrS.Type)
case attrS.Optional:
val = cty.NullVal(attrS.Type)
default:
return cty.UnknownVal(b.ImpliedType()), path.NewErrorf("attribute %q is required", name)
}
val, err := attrS.coerceValue(val, append(path, cty.GetAttrStep{Name: name}))
if err != nil {
return cty.UnknownVal(b.ImpliedType()), err
}
attrs[name] = val
}
for typeName, blockS := range b.BlockTypes {
switch blockS.Nesting {
case NestingSingle:
switch {
case ty.HasAttribute(typeName):
var err error
val := in.GetAttr(typeName)
attrs[typeName], err = blockS.coerceValue(val, append(path, cty.GetAttrStep{Name: typeName}))
if err != nil {
return cty.UnknownVal(b.ImpliedType()), err
}
case blockS.MinItems != 1 && blockS.MaxItems != 1:
attrs[typeName] = cty.NullVal(blockS.ImpliedType())
default:
// We use the word "attribute" here because we're talking about
// the cty sense of that word rather than the HCL sense.
return cty.UnknownVal(b.ImpliedType()), path.NewErrorf("attribute %q is required", typeName)
}
case NestingList:
switch {
case ty.HasAttribute(typeName):
coll := in.GetAttr(typeName)
switch {
case coll.IsNull():
attrs[typeName] = cty.NullVal(cty.List(b.ImpliedType()))
continue
case !coll.IsKnown():
attrs[typeName] = cty.UnknownVal(cty.List(b.ImpliedType()))
continue
}
if !coll.CanIterateElements() {
return cty.UnknownVal(b.ImpliedType()), path.NewErrorf("attribute %q must be a list", typeName)
}
l := coll.LengthInt()
if l < blockS.MinItems {
return cty.UnknownVal(b.ImpliedType()), path.NewErrorf("insufficient items for attribute %q; must have at least %d", typeName, blockS.MinItems)
}
if l > blockS.MaxItems && blockS.MaxItems > 0 {
return cty.UnknownVal(b.ImpliedType()), path.NewErrorf("too many items for attribute %q; must have at least %d", typeName, blockS.MinItems)
}
if l == 0 {
attrs[typeName] = cty.ListValEmpty(b.ImpliedType())
continue
}
elems := make([]cty.Value, 0, l)
for it := in.ElementIterator(); it.Next(); {
var err error
_, val := it.Element()
val, err = blockS.coerceValue(val, append(path, cty.GetAttrStep{Name: typeName}))
if err != nil {
return cty.UnknownVal(b.ImpliedType()), err
}
elems = append(elems, val)
}
attrs[typeName] = cty.ListVal(elems)
case blockS.MinItems == 0:
attrs[typeName] = cty.ListValEmpty(blockS.ImpliedType())
default:
return cty.UnknownVal(b.ImpliedType()), path.NewErrorf("attribute %q is required", typeName)
}
case NestingSet:
switch {
case ty.HasAttribute(typeName):
coll := in.GetAttr(typeName)
switch {
case coll.IsNull():
attrs[typeName] = cty.NullVal(cty.Set(b.ImpliedType()))
continue
case !coll.IsKnown():
attrs[typeName] = cty.UnknownVal(cty.Set(b.ImpliedType()))
continue
}
if !coll.CanIterateElements() {
return cty.UnknownVal(b.ImpliedType()), path.NewErrorf("attribute %q must be a set", typeName)
}
l := coll.LengthInt()
if l < blockS.MinItems {
return cty.UnknownVal(b.ImpliedType()), path.NewErrorf("insufficient items for attribute %q; must have at least %d", typeName, blockS.MinItems)
}
if l > blockS.MaxItems && blockS.MaxItems > 0 {
return cty.UnknownVal(b.ImpliedType()), path.NewErrorf("too many items for attribute %q; must have at least %d", typeName, blockS.MinItems)
}
if l == 0 {
attrs[typeName] = cty.SetValEmpty(b.ImpliedType())
continue
}
elems := make([]cty.Value, 0, l)
for it := in.ElementIterator(); it.Next(); {
var err error
_, val := it.Element()
val, err = blockS.coerceValue(val, append(path, cty.GetAttrStep{Name: typeName}))
if err != nil {
return cty.UnknownVal(b.ImpliedType()), err
}
elems = append(elems, val)
}
attrs[typeName] = cty.SetVal(elems)
case blockS.MinItems == 0:
attrs[typeName] = cty.SetValEmpty(blockS.ImpliedType())
default:
return cty.UnknownVal(b.ImpliedType()), path.NewErrorf("attribute %q is required", typeName)
}
case NestingMap:
switch {
case ty.HasAttribute(typeName):
coll := in.GetAttr(typeName)
switch {
case coll.IsNull():
attrs[typeName] = cty.NullVal(cty.Map(b.ImpliedType()))
continue
case !coll.IsKnown():
attrs[typeName] = cty.UnknownVal(cty.Map(b.ImpliedType()))
continue
}
if !coll.CanIterateElements() {
return cty.UnknownVal(b.ImpliedType()), path.NewErrorf("attribute %q must be a map", typeName)
}
l := coll.LengthInt()
if l == 0 {
attrs[typeName] = cty.MapValEmpty(b.ImpliedType())
continue
}
elems := make(map[string]cty.Value)
for it := in.ElementIterator(); it.Next(); {
var err error
key, val := it.Element()
if key.Type() != cty.String || key.IsNull() || !key.IsKnown() {
return cty.UnknownVal(b.ImpliedType()), path.NewErrorf("attribute %q must be a map", typeName)
}
val, err = blockS.coerceValue(val, append(path, cty.GetAttrStep{Name: typeName}))
if err != nil {
return cty.UnknownVal(b.ImpliedType()), err
}
elems[key.AsString()] = val
}
attrs[typeName] = cty.MapVal(elems)
default:
attrs[typeName] = cty.MapValEmpty(blockS.ImpliedType())
}
default:
// should never happen because above is exhaustive
panic(fmt.Errorf("unsupported nesting mode %#v", blockS.Nesting))
}
}
return cty.ObjectVal(attrs), nil
}
func (a *Attribute) coerceValue(in cty.Value, path cty.Path) (cty.Value, error) {
val, err := convert.Convert(in, a.Type)
if err != nil {
return cty.UnknownVal(a.Type), path.NewError(err)
}
return val, nil
}

View File

@ -0,0 +1,233 @@
package configschema
import (
"testing"
"github.com/zclconf/go-cty/cty"
)
func TestCoerceValue(t *testing.T) {
tests := map[string]struct {
Schema *Block
Input cty.Value
WantValue cty.Value
WantErr string
}{
"empty schema and value": {
&Block{},
cty.EmptyObjectVal,
cty.EmptyObjectVal,
``,
},
"attribute present": {
&Block{
Attributes: map[string]*Attribute{
"foo": {
Type: cty.String,
Optional: true,
},
},
},
cty.ObjectVal(map[string]cty.Value{
"foo": cty.True,
}),
cty.ObjectVal(map[string]cty.Value{
"foo": cty.StringVal("true"),
}),
``,
},
"single block present": {
&Block{
BlockTypes: map[string]*NestedBlock{
"foo": {
Block: Block{},
Nesting: NestingSingle,
},
},
},
cty.ObjectVal(map[string]cty.Value{
"foo": cty.EmptyObjectVal,
}),
cty.ObjectVal(map[string]cty.Value{
"foo": cty.EmptyObjectVal,
}),
``,
},
"single block wrong type": {
&Block{
BlockTypes: map[string]*NestedBlock{
"foo": {
Block: Block{},
Nesting: NestingSingle,
},
},
},
cty.ObjectVal(map[string]cty.Value{
"foo": cty.True,
}),
cty.DynamicVal,
`an object is required`,
},
"missing optional attribute": {
&Block{
Attributes: map[string]*Attribute{
"foo": {
Type: cty.String,
Optional: true,
},
},
},
cty.EmptyObjectVal,
cty.ObjectVal(map[string]cty.Value{
"foo": cty.NullVal(cty.String),
}),
``,
},
"missing optional single block": {
&Block{
BlockTypes: map[string]*NestedBlock{
"foo": {
Block: Block{},
Nesting: NestingSingle,
},
},
},
cty.EmptyObjectVal,
cty.ObjectVal(map[string]cty.Value{
"foo": cty.NullVal(cty.EmptyObject),
}),
``,
},
"missing optional list block": {
&Block{
BlockTypes: map[string]*NestedBlock{
"foo": {
Block: Block{},
Nesting: NestingList,
},
},
},
cty.EmptyObjectVal,
cty.ObjectVal(map[string]cty.Value{
"foo": cty.ListValEmpty(cty.EmptyObject),
}),
``,
},
"missing optional set block": {
&Block{
BlockTypes: map[string]*NestedBlock{
"foo": {
Block: Block{},
Nesting: NestingSet,
},
},
},
cty.EmptyObjectVal,
cty.ObjectVal(map[string]cty.Value{
"foo": cty.SetValEmpty(cty.EmptyObject),
}),
``,
},
"missing optional map block": {
&Block{
BlockTypes: map[string]*NestedBlock{
"foo": {
Block: Block{},
Nesting: NestingMap,
},
},
},
cty.EmptyObjectVal,
cty.ObjectVal(map[string]cty.Value{
"foo": cty.MapValEmpty(cty.EmptyObject),
}),
``,
},
"missing required attribute": {
&Block{
Attributes: map[string]*Attribute{
"foo": {
Type: cty.String,
Required: true,
},
},
},
cty.EmptyObjectVal,
cty.DynamicVal,
`attribute "foo" is required`,
},
"missing required single block": {
&Block{
BlockTypes: map[string]*NestedBlock{
"foo": {
Block: Block{},
Nesting: NestingSingle,
MinItems: 1,
MaxItems: 1,
},
},
},
cty.EmptyObjectVal,
cty.DynamicVal,
`attribute "foo" is required`,
},
"missing required list block": {
&Block{
BlockTypes: map[string]*NestedBlock{
"foo": {
Block: Block{},
Nesting: NestingList,
MinItems: 1,
},
},
},
cty.EmptyObjectVal,
cty.DynamicVal,
`attribute "foo" is required`,
},
"missing required set block": {
&Block{
BlockTypes: map[string]*NestedBlock{
"foo": {
Block: Block{},
Nesting: NestingList,
MinItems: 1,
},
},
},
cty.EmptyObjectVal,
cty.DynamicVal,
`attribute "foo" is required`,
},
"extraneous attribute": {
&Block{},
cty.ObjectVal(map[string]cty.Value{
"foo": cty.StringVal("bar"),
}),
cty.DynamicVal,
`unexpected attribute "foo"`,
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
gotValue, gotErrObj := test.Schema.CoerceValue(test.Input)
if gotErrObj == nil {
if test.WantErr != "" {
t.Fatalf("coersion succeeded; want error: %q", test.WantErr)
}
} else {
gotErr := gotErrObj.Error()
if gotErr != test.WantErr {
t.Fatalf("wrong error\ngot: %s\nwant: %s", gotErr, test.WantErr)
}
return
}
if !gotValue.RawEquals(test.WantValue) {
t.Errorf("wrong result\ninput: %#v\ngot: %#v\nwant: %#v", test.Input, gotValue, test.WantValue)
}
})
}
}