Build reusable schema descriptors
This commit is contained in:
304
schema_test.go
Normal file
304
schema_test.go
Normal file
@@ -0,0 +1,304 @@
|
||||
package configkit
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type namedCount int32
|
||||
|
||||
type textValue struct {
|
||||
value string
|
||||
}
|
||||
|
||||
func (v textValue) MarshalText() ([]byte, error) {
|
||||
return []byte(v.value), nil
|
||||
}
|
||||
|
||||
func (v *textValue) UnmarshalText(data []byte) error {
|
||||
v.value = string(data)
|
||||
return nil
|
||||
}
|
||||
|
||||
type marshalOnly string
|
||||
|
||||
func (v marshalOnly) MarshalText() ([]byte, error) {
|
||||
return []byte(v), nil
|
||||
}
|
||||
|
||||
type unmarshalOnly string
|
||||
|
||||
func (v *unmarshalOnly) UnmarshalText(data []byte) error {
|
||||
*v = unmarshalOnly(data)
|
||||
return nil
|
||||
}
|
||||
|
||||
type recursiveConfig struct {
|
||||
Name string `yaml:"name"`
|
||||
Next *recursiveConfig `yaml:"next"`
|
||||
Children map[string]recursiveConfig `yaml:"children"`
|
||||
}
|
||||
|
||||
func TestBuildSchemaDescribesSupportedTypes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Embedded struct {
|
||||
InlineName string `yaml:"inline_name"`
|
||||
}
|
||||
type nested struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
}
|
||||
type config struct {
|
||||
HTTPServer string `yaml:"server,omitempty" configkit:"secret"`
|
||||
Count namedCount `yaml:"count"`
|
||||
Delay time.Duration `yaml:"delay"`
|
||||
Custom textValue `yaml:"custom"`
|
||||
Optional *textValue `yaml:"optional"`
|
||||
Nested nested `yaml:"nested"`
|
||||
Objects map[string]nested `yaml:"objects"`
|
||||
Items []nested `yaml:"items"`
|
||||
Fixed [2]string `yaml:"fixed"`
|
||||
Dynamic any `yaml:"dynamic"`
|
||||
*Embedded `yaml:",inline"`
|
||||
Extras map[string]int `yaml:",inline"`
|
||||
UntaggedField bool
|
||||
Ignored chan int `yaml:"-"`
|
||||
private chan int
|
||||
}
|
||||
|
||||
schema, issue := buildSchema(reflect.TypeFor[config]())
|
||||
if issue != nil {
|
||||
t.Fatalf("buildSchema() issue = %#v", issue)
|
||||
}
|
||||
root := schema.root
|
||||
checks := map[string]descriptorKind{
|
||||
"server": descriptorString,
|
||||
"count": descriptorSigned,
|
||||
"delay": descriptorDuration,
|
||||
"custom": descriptorText,
|
||||
"optional": descriptorPointer,
|
||||
"nested": descriptorStruct,
|
||||
"objects": descriptorMap,
|
||||
"items": descriptorSlice,
|
||||
"fixed": descriptorArray,
|
||||
"dynamic": descriptorDynamic,
|
||||
"inline_name": descriptorString,
|
||||
"untaggedfield": descriptorBoolean,
|
||||
}
|
||||
for name, want := range checks {
|
||||
position, ok := root.fieldByName[name]
|
||||
if !ok {
|
||||
t.Errorf("field %q missing", name)
|
||||
continue
|
||||
}
|
||||
got := root.fields[position].descriptor.kind
|
||||
if got != want {
|
||||
t.Errorf("field %q kind = %v, want %v", name, got, want)
|
||||
}
|
||||
}
|
||||
server := root.fields[root.fieldByName["server"]]
|
||||
if !server.secret {
|
||||
t.Fatal("secret tag was not retained")
|
||||
}
|
||||
if root.inlineMap == nil || root.inlineMap.descriptor.kind != descriptorMap {
|
||||
t.Fatalf("inline map = %#v", root.inlineMap)
|
||||
}
|
||||
if mapped, mapIssue := buildSchema(reflect.TypeFor[map[string]nested]()); mapIssue != nil || mapped.root.kind != descriptorMap {
|
||||
t.Fatalf("string-keyed root map = %#v, issue=%#v", mapped, mapIssue)
|
||||
}
|
||||
if _, exists := root.fieldByName["ignored"]; exists {
|
||||
t.Fatal("ignored field was bound")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSchemaSupportsRecursiveDescriptorsWithoutSharingLoads(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
first, issue := buildSchema(reflect.TypeFor[recursiveConfig]())
|
||||
if issue != nil {
|
||||
t.Fatal(issue.message)
|
||||
}
|
||||
next := first.root.fields[first.root.fieldByName["next"]].descriptor
|
||||
if next.kind != descriptorPointer || next.element != first.root {
|
||||
t.Fatal("recursive pointer did not refer to the existing descriptor")
|
||||
}
|
||||
children := first.root.fields[first.root.fieldByName["children"]].descriptor
|
||||
if children.kind != descriptorMap || children.element != first.root {
|
||||
t.Fatal("recursive map did not refer to the existing descriptor")
|
||||
}
|
||||
second, issue := buildSchema(reflect.TypeFor[recursiveConfig]())
|
||||
if issue != nil {
|
||||
t.Fatal(issue.message)
|
||||
}
|
||||
if first.root == second.root {
|
||||
t.Fatal("descriptor graph was cached across loads")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSchemaRejectsInvalidRootsTypesTagsAndBindings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type InlineA struct {
|
||||
Value string `yaml:"same"`
|
||||
}
|
||||
type InlineB struct {
|
||||
Value string `yaml:"same"`
|
||||
}
|
||||
type ambiguous struct {
|
||||
InlineA `yaml:",inline"`
|
||||
InlineB `yaml:",inline"`
|
||||
}
|
||||
type directConflict struct {
|
||||
Same string `yaml:"same"`
|
||||
InlineA `yaml:",inline"`
|
||||
}
|
||||
type twoMaps struct {
|
||||
First map[string]string `yaml:",inline"`
|
||||
Second map[string]string `yaml:",inline"`
|
||||
}
|
||||
type invalidInline struct {
|
||||
Value string `yaml:",inline"`
|
||||
}
|
||||
type invalidMap struct {
|
||||
Values map[int]string `yaml:"values"`
|
||||
}
|
||||
type unsupported struct {
|
||||
Value complex64 `yaml:"value"`
|
||||
}
|
||||
type badOption struct {
|
||||
Value string `yaml:"value,flow"`
|
||||
}
|
||||
type badConfigTag struct {
|
||||
Value string `configkit:"private"`
|
||||
}
|
||||
type missingMarshal struct {
|
||||
Value unmarshalOnly `yaml:"value"`
|
||||
}
|
||||
type missingUnmarshal struct {
|
||||
Value marshalOnly `yaml:"value"`
|
||||
}
|
||||
type RecursiveInline struct {
|
||||
*RecursiveInline `yaml:",inline"`
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
typeOf reflect.Type
|
||||
code Code
|
||||
}{
|
||||
{name: "nil root", typeOf: nil, code: CodeSchemaRoot},
|
||||
{name: "pointer root", typeOf: reflect.TypeFor[*recursiveConfig](), code: CodeSchemaRoot},
|
||||
{name: "slice root", typeOf: reflect.TypeFor[[]string](), code: CodeSchemaRoot},
|
||||
{name: "non-string root map", typeOf: reflect.TypeFor[map[int]string](), code: CodeSchemaRoot},
|
||||
{name: "text scalar root", typeOf: reflect.TypeFor[textValue](), code: CodeSchemaRoot},
|
||||
{name: "ambiguous fields", typeOf: reflect.TypeFor[ambiguous](), code: CodeSchemaAmbiguousField},
|
||||
{name: "direct inline conflict", typeOf: reflect.TypeFor[directConflict](), code: CodeSchemaAmbiguousField},
|
||||
{name: "multiple inline maps", typeOf: reflect.TypeFor[twoMaps](), code: CodeSchemaInline},
|
||||
{name: "invalid inline", typeOf: reflect.TypeFor[invalidInline](), code: CodeSchemaInline},
|
||||
{name: "invalid nested map", typeOf: reflect.TypeFor[invalidMap](), code: CodeSchemaUnsupportedType},
|
||||
{name: "unsupported kind", typeOf: reflect.TypeFor[unsupported](), code: CodeSchemaUnsupportedType},
|
||||
{name: "unsupported YAML option", typeOf: reflect.TypeFor[badOption](), code: CodeSchemaTag},
|
||||
{name: "unsupported ConfigKit tag", typeOf: reflect.TypeFor[badConfigTag](), code: CodeSchemaTag},
|
||||
{name: "missing text marshal", typeOf: reflect.TypeFor[missingMarshal](), code: CodeSchemaTextCodec},
|
||||
{name: "missing text unmarshal", typeOf: reflect.TypeFor[missingUnmarshal](), code: CodeSchemaTextCodec},
|
||||
{name: "recursive inline", typeOf: reflect.TypeFor[RecursiveInline](), code: CodeSchemaInline},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if _, issue := buildSchema(test.typeOf); issue == nil || issue.code != test.code {
|
||||
t.Fatalf("issue = %#v, want %s", issue, test.code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSchemaDoesNotExecuteApplicationMethods(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type config struct {
|
||||
Value panicText `yaml:"value"`
|
||||
Structural yamlHookStruct `yaml:"structural"`
|
||||
}
|
||||
if _, issue := buildSchema(reflect.TypeFor[config]()); issue != nil {
|
||||
t.Fatal(issue.message)
|
||||
}
|
||||
}
|
||||
|
||||
type panicText struct{}
|
||||
|
||||
type yamlHookStruct struct {
|
||||
Value string `yaml:"value"`
|
||||
}
|
||||
|
||||
func (*yamlHookStruct) UnmarshalYAML(any) error {
|
||||
panic("descriptor construction called parser hook")
|
||||
}
|
||||
|
||||
func (panicText) MarshalText() ([]byte, error) {
|
||||
panic("descriptor construction called MarshalText")
|
||||
}
|
||||
|
||||
func (*panicText) UnmarshalText([]byte) error {
|
||||
panic("descriptor construction called UnmarshalText")
|
||||
}
|
||||
|
||||
func (panicText) UnmarshalYAML(any) error {
|
||||
panic("descriptor construction called parser hook")
|
||||
}
|
||||
|
||||
func TestResolveSchemaPathUsesContainerSemantics(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type item struct {
|
||||
Name string `yaml:"name"`
|
||||
}
|
||||
type config struct {
|
||||
Items []item `yaml:"items"`
|
||||
Values map[string]item `yaml:"values"`
|
||||
Fixed [2]string `yaml:"fixed"`
|
||||
Dynamic any `yaml:"dynamic"`
|
||||
Extras map[string]string `yaml:",inline"`
|
||||
}
|
||||
schema, issue := buildSchema(reflect.TypeFor[config]())
|
||||
if issue != nil {
|
||||
t.Fatal(issue.message)
|
||||
}
|
||||
|
||||
valid := []Path{
|
||||
Keys("items").Index(0).Key("name"),
|
||||
mustPath(t, "/items/0/name"),
|
||||
Keys("values", "0", "name"),
|
||||
mustPath(t, "/values/0/name"),
|
||||
Keys("fixed").Index(1),
|
||||
Keys("dynamic", "anything").Index(9),
|
||||
Keys("application_key"),
|
||||
}
|
||||
for _, path := range valid {
|
||||
if _, ok := resolveSchemaPath(schema, path); !ok {
|
||||
t.Errorf("valid path %q rejected", path.String())
|
||||
}
|
||||
}
|
||||
invalid := []Path{
|
||||
Keys("missing", "field"),
|
||||
Keys("items", "0", "name"),
|
||||
mustPath(t, "/items/00/name"),
|
||||
Keys("fixed").Index(2),
|
||||
Keys("values").Index(0),
|
||||
Path{}.Index(-1),
|
||||
}
|
||||
for _, path := range invalid {
|
||||
if _, ok := resolveSchemaPath(schema, path); ok {
|
||||
t.Errorf("invalid path %q accepted", path.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustPath(t *testing.T, pointer string) Path {
|
||||
t.Helper()
|
||||
path, err := ParsePath(pointer)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
Reference in New Issue
Block a user