config/configschema: InternalValidate for blocks

This checks that a schema complies with the documented constraints on
which values are valid. It is primarily intended for use in tests.
This commit is contained in:
Martin Atkins 2017-10-02 18:46:30 -07:00
parent d712a04c32
commit f117906bdb
2 changed files with 330 additions and 0 deletions

View File

@ -0,0 +1,92 @@
package configschema
import (
"fmt"
"regexp"
"github.com/zclconf/go-cty/cty"
multierror "github.com/hashicorp/go-multierror"
)
var validName = regexp.MustCompile(`^[a-z0-9_]+$`)
// InternalValidate returns an error if the receiving block and its child
// schema definitions have any consistencies with the documented rules for
// valid schema.
//
// This is intended to be used within unit tests to detect when a given
// schema is invalid.
func (b *Block) InternalValidate() error {
if b == nil {
return fmt.Errorf("top-level block schema is nil")
}
return b.internalValidate("", nil)
}
func (b *Block) internalValidate(prefix string, err error) error {
for name, attrS := range b.Attributes {
if attrS == nil {
err = multierror.Append(err, fmt.Errorf("%s%s: attribute schema is nil", prefix, name))
continue
}
if !validName.MatchString(name) {
err = multierror.Append(err, fmt.Errorf("%s%s: name may contain only lowercase letters, digits and underscores", prefix, name))
}
if attrS.Optional == false && attrS.Required == false && attrS.Computed == false {
err = multierror.Append(err, fmt.Errorf("%s%s: must set Optional, Required or Computed", prefix, name))
}
if attrS.Optional && attrS.Required {
err = multierror.Append(err, fmt.Errorf("%s%s: cannot set both Optional and Required", prefix, name))
}
if attrS.Computed && attrS.Required {
err = multierror.Append(err, fmt.Errorf("%s%s: cannot set both Computed and Required", prefix, name))
}
if attrS.Type == cty.NilType {
err = multierror.Append(err, fmt.Errorf("%s%s: Type must be set to something other than cty.NilType", prefix, name))
}
}
for name, blockS := range b.BlockTypes {
if blockS == nil {
err = multierror.Append(err, fmt.Errorf("%s%s: block schema is nil", prefix, name))
continue
}
if _, isAttr := b.Attributes[name]; isAttr {
err = multierror.Append(err, fmt.Errorf("%s%s: name defined as both attribute and child block type", prefix, name))
} else if !validName.MatchString(name) {
err = multierror.Append(err, fmt.Errorf("%s%s: name may contain only lowercase letters, digits and underscores", prefix, name))
}
if blockS.MinItems < 0 || blockS.MaxItems < 0 {
err = multierror.Append(err, fmt.Errorf("%s%s: MinItems and MaxItems must both be greater than zero", prefix, name))
}
switch blockS.Nesting {
case NestingSingle:
switch {
case blockS.MinItems != blockS.MaxItems:
err = multierror.Append(err, fmt.Errorf("%s%s: MinItems and MaxItems must match in NestingSingle mode", prefix, name))
case blockS.MinItems < 0 || blockS.MinItems > 1:
err = multierror.Append(err, fmt.Errorf("%s%s: MinItems and MaxItems must be set to either 0 or 1 in NestingSingle mode", prefix, name))
}
case NestingList, NestingSet:
if blockS.MinItems > blockS.MaxItems && blockS.MaxItems != 0 {
err = multierror.Append(err, fmt.Errorf("%s%s: MinItems must be less than or equal to MaxItems in %s mode", prefix, name, blockS.Nesting))
}
case NestingMap:
if blockS.MinItems != 0 || blockS.MaxItems != 0 {
err = multierror.Append(err, fmt.Errorf("%s%s: MinItems and MaxItems must both be 0 in NestingMap mode", prefix, name))
}
default:
err = multierror.Append(err, fmt.Errorf("%s%s: invalid nesting mode %s", prefix, name, blockS.Nesting))
}
subPrefix := prefix + name + "."
err = blockS.Block.internalValidate(subPrefix, err)
}
return err
}

View File

@ -0,0 +1,238 @@
package configschema
import (
"testing"
"github.com/zclconf/go-cty/cty"
multierror "github.com/hashicorp/go-multierror"
)
func TestBlockInternalValidate(t *testing.T) {
tests := map[string]struct {
Block *Block
ErrCount int
}{
"empty": {
&Block{},
0,
},
"valid": {
&Block{
Attributes: map[string]*Attribute{
"foo": &Attribute{
Type: cty.String,
Required: true,
},
"bar": &Attribute{
Type: cty.String,
Optional: true,
},
"baz": &Attribute{
Type: cty.String,
Computed: true,
},
"baz_maybe": &Attribute{
Type: cty.String,
Optional: true,
Computed: true,
},
},
BlockTypes: map[string]*NestedBlock{
"single": &NestedBlock{
Nesting: NestingSingle,
Block: Block{},
},
"single_required": &NestedBlock{
Nesting: NestingSingle,
Block: Block{},
MinItems: 1,
MaxItems: 1,
},
"list": &NestedBlock{
Nesting: NestingList,
Block: Block{},
},
"list_required": &NestedBlock{
Nesting: NestingList,
Block: Block{},
MinItems: 1,
},
"set": &NestedBlock{
Nesting: NestingSet,
Block: Block{},
},
"set_required": &NestedBlock{
Nesting: NestingSet,
Block: Block{},
MinItems: 1,
},
"map": &NestedBlock{
Nesting: NestingMap,
Block: Block{},
},
},
},
0,
},
"attribute with no flags set": {
&Block{
Attributes: map[string]*Attribute{
"foo": &Attribute{
Type: cty.String,
},
},
},
1, // must set one of the flags
},
"attribute required and optional": {
&Block{
Attributes: map[string]*Attribute{
"foo": &Attribute{
Type: cty.String,
Required: true,
Optional: true,
},
},
},
1, // both required and optional
},
"attribute required and computed": {
&Block{
Attributes: map[string]*Attribute{
"foo": &Attribute{
Type: cty.String,
Required: true,
Computed: true,
},
},
},
1, // both required and computed
},
"attribute optional and computed": {
&Block{
Attributes: map[string]*Attribute{
"foo": &Attribute{
Type: cty.String,
Optional: true,
Computed: true,
},
},
},
0,
},
"attribute with missing type": {
&Block{
Attributes: map[string]*Attribute{
"foo": &Attribute{
Optional: true,
},
},
},
1, // Type must be set
},
"attribute with invalid name": {
&Block{
Attributes: map[string]*Attribute{
"fooBar": &Attribute{
Type: cty.String,
Optional: true,
},
},
},
1, // name may not contain uppercase letters
},
"block type with invalid name": {
&Block{
BlockTypes: map[string]*NestedBlock{
"fooBar": &NestedBlock{
Nesting: NestingSingle,
},
},
},
1, // name may not contain uppercase letters
},
"colliding names": {
&Block{
Attributes: map[string]*Attribute{
"foo": &Attribute{
Type: cty.String,
Optional: true,
},
},
BlockTypes: map[string]*NestedBlock{
"foo": &NestedBlock{
Nesting: NestingSingle,
},
},
},
1, // "foo" is defined as both attribute and block type
},
"nested block with badness": {
&Block{
BlockTypes: map[string]*NestedBlock{
"bad": &NestedBlock{
Nesting: NestingSingle,
Block: Block{
Attributes: map[string]*Attribute{
"nested_bad": &Attribute{
Type: cty.String,
Required: true,
Optional: true,
},
},
},
},
},
},
1, // nested_bad is both required and optional
},
"nil": {
nil,
1, // block is nil
},
"nil attr": {
&Block{
Attributes: map[string]*Attribute{
"bad": nil,
},
},
1, // attribute schema is nil
},
"nil block type": {
&Block{
BlockTypes: map[string]*NestedBlock{
"bad": nil,
},
},
1, // block schema is nil
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
errs := multierrorErrors(test.Block.InternalValidate())
if got, want := len(errs), test.ErrCount; got != want {
t.Errorf("wrong number of errors %d; want %d", got, want)
for _, err := range errs {
t.Logf("- %s", err.Error())
}
}
})
}
}
func multierrorErrors(err error) []error {
// A function like this should really be part of the multierror package...
if err == nil {
return nil
}
switch terr := err.(type) {
case *multierror.Error:
return terr.Errors
default:
return []error{err}
}
}