Build reusable schema descriptors
This commit is contained in:
@@ -69,6 +69,41 @@ boolean/null words are rejected unless quoted.
|
||||
Mapping keys use the same scalar classification and must classify as strings. Quote
|
||||
keys that otherwise classify as a boolean, number, or null.
|
||||
|
||||
## Application schema binding
|
||||
|
||||
ConfigKit builds a private reflection descriptor graph for each schema operation;
|
||||
it keeps no global mutable descriptor cache. A configuration root must be a struct
|
||||
or string-keyed map. Nested supported shapes are:
|
||||
|
||||
- booleans, strings, signed and unsigned integers, and finite floating-point types;
|
||||
- `time.Duration` and paired `encoding.TextMarshaler`/`TextUnmarshaler` scalars;
|
||||
- pointers, structs, string-keyed maps, slices, fixed arrays, and `any`; and
|
||||
- recursive type definitions whose eventual application values are acyclic.
|
||||
|
||||
Named scalar types retain their Go identity. `uintptr`, complex numbers, functions,
|
||||
channels, unsafe pointers, non-string map keys, and nonempty interfaces are not
|
||||
configuration types. A custom text scalar must provide both directions on its value
|
||||
or pointer method set. Building a descriptor only inspects method sets; it never
|
||||
executes application methods or parser-specific hooks.
|
||||
|
||||
Exported struct fields use an explicit `yaml` name or the lowercase Go field name.
|
||||
Unexported fields and `yaml:"-"` are ignored. `omitempty` is accepted but does not
|
||||
change binding or future projection. An explicit `yaml:",inline"` flattens a struct
|
||||
or pointer-to-struct, or designates at most one string-keyed inline map for otherwise
|
||||
unmatched names. Ambiguous names, named inline fields, multiple inline maps, and
|
||||
unsupported or malformed tag options are schema errors. Embedded fields are ordinary
|
||||
named fields unless explicitly inlined. `configkit:"secret"` is the only current
|
||||
ConfigKit field tag and records sensitivity for later lifecycle work.
|
||||
|
||||
Schema path traversal uses these same bindings. Explicit key segments address
|
||||
mappings, explicit index segments address sequences, and parsed numeric pointer
|
||||
tokens become indices only in a sequence. Sequence indices cannot have leading
|
||||
zeroes; fixed-array indices are bounds checked. Dynamic `any` and inline-map entries
|
||||
remain schema-valid for application-defined descendants.
|
||||
|
||||
Descriptors do not yet decode documents, clone defaults, or project application
|
||||
values. Those operations will reuse this graph as their implementation lands.
|
||||
|
||||
## Safe failures
|
||||
|
||||
Source failures use stable `source.*` codes and YAML failures use stable `yaml.*`
|
||||
|
||||
@@ -9,7 +9,9 @@ module and package documentation boundary. Its implemented public primitives are
|
||||
- opaque file, byte, reader, and no-input source construction and bounded reads;
|
||||
- a parser-independent ordered tree in `internal/document`; and
|
||||
- the sole upstream YAML integration in `internal/yaml`, which translates validated
|
||||
input into that owned tree and exposes no parser types outside its boundary.
|
||||
input into that owned tree and exposes no parser types outside its boundary; and
|
||||
- a per-operation private reflection descriptor graph in `schema.go` for supported
|
||||
types, YAML field binding, inlining, secret tags, recursion, and semantic paths.
|
||||
|
||||
The [configuration input contract](../config.md) owns implemented source and YAML
|
||||
behavior. ConfigKit intentionally exposes no typed loading or decoding API yet.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Status and ownership
|
||||
|
||||
Status: implementation in progress; Stages 1–3 are complete and later stages are
|
||||
Status: implementation in progress; Stages 1–4 are complete and later stages are
|
||||
pending.
|
||||
|
||||
Audience: a `gpt-5.6-terra` coding agent implementing one stage per prompt, in
|
||||
@@ -463,7 +463,27 @@ Acceptance:
|
||||
|
||||
## Stage 4 — Build the reusable schema binding graph
|
||||
|
||||
Status: pending.
|
||||
Status: complete.
|
||||
|
||||
Completion evidence (2026-09-06):
|
||||
|
||||
- Implemented a per-operation private descriptor graph in [`schema.go`](../../schema.go)
|
||||
for structural roots, ordinary/named scalars, duration and paired text scalars,
|
||||
pointers, structs, string-keyed maps, slices, arrays, dynamic values, and recursive
|
||||
definitions. No global cache or application method execution is involved.
|
||||
- Implemented declared-field YAML binding, ignored/private fields, explicit struct/
|
||||
pointer/map inlining, ambiguity and tag validation, one inline map, secret-tag
|
||||
retention, and schema-aware key/index path traversal. Runtime default collisions,
|
||||
cyclic values, cloning, projection, and decoding remain assigned to later stages.
|
||||
- Added the [current schema contract](../config.md#application-schema-binding) and
|
||||
updated the component inventory and target-roadmap ownership links.
|
||||
- Focused tests cover nested and inline conflicts, ignored/private fields, maps of
|
||||
structs, named scalars, pointer text method sets, missing codec pairs, root and
|
||||
unsupported-kind rejection, recursive descriptors, side-effect freedom, dynamic
|
||||
and inline-map paths, parsed numeric indices, and array bounds. Verified
|
||||
`go test ./...`, `go vet ./...`, `go build ./...`, `go mod tidy -diff`, and
|
||||
`git diff --check` with Go 1.26.7 on linux/amd64. No deviations were required;
|
||||
typed value operations remain later work.
|
||||
|
||||
Read: feature [typed decoding](initial.md#typed-decoding) and
|
||||
[type/projection contract](initial.md#supported-types-and-projection).
|
||||
|
||||
@@ -584,17 +584,16 @@ through validation. Null never means reset to the application default.
|
||||
|
||||
## Typed decoding
|
||||
|
||||
The schema-binding portion of this target is implemented. The [current application
|
||||
schema contract](../config.md#application-schema-binding) owns root/type support,
|
||||
field and tag binding, inlining, text-codec pairing, and schema-aware path rules.
|
||||
The remaining document-to-value behavior below is target decoding work.
|
||||
|
||||
Strictness must be enforced by ConfigKit across the effective tree:
|
||||
|
||||
- Unknown struct fields, type mismatches, overflow, and invalid null are errors.
|
||||
- Support ordinary exported fields and `yaml` names/ignore tags. Untagged field
|
||||
names use the lowercase Go field name. Embedded structs follow the same rule
|
||||
unless explicitly tagged `yaml:",inline"`; inline struct fields contribute to
|
||||
the containing mapping. Permit at most one inline string-keyed map per object,
|
||||
which receives otherwise unmatched keys. Duplicate declared field names and
|
||||
default inline-map entries colliding with declared fields are schema errors.
|
||||
Without an inline map, unmatched keys remain errors. Ignore `omitempty` for
|
||||
decoding, defaults, and effective inspection.
|
||||
- Without an inline map, unmatched keys remain errors. Default inline-map entries
|
||||
colliding with declared fields are checked when runtime defaults are introduced.
|
||||
- String-keyed maps accept application-defined keys. Struct values inside them
|
||||
remain strict. `map[string]any` permits arbitrary keys and recursively accepts
|
||||
the supported scalar/container model; application validation owns its schema.
|
||||
@@ -1298,12 +1297,10 @@ model, not the application's separately constructed runtime module objects.
|
||||
|
||||
### Supported types and projection
|
||||
|
||||
The supported root `T` is a struct or string-keyed map. Within it, support ordinary
|
||||
exported scalar fields, pointers, structs, string-keyed maps, slices, fixed-length
|
||||
arrays, dynamic `any`, and the explicitly supported scalar conversions. Reject
|
||||
unsupported kinds, non-string map keys, cyclic object graphs, and ambiguous field
|
||||
bindings as schema/projection errors. Arrays require matching sequence length.
|
||||
`yaml:"-"` fields and unexported fields are outside the configuration projection;
|
||||
Supported schema types and field bindings are implemented and owned by the
|
||||
[current application schema contract](../config.md#application-schema-binding).
|
||||
Projection must additionally reject cyclic object graphs. Arrays require matching
|
||||
sequence length. Ignored and unexported fields stay outside configuration projection;
|
||||
hooks must not use them to carry runtime resources or configuration needing
|
||||
inspection. Non-configuration runtime state belongs in separate application types.
|
||||
|
||||
|
||||
@@ -52,6 +52,13 @@ const (
|
||||
CodeYAMLScalar Code = "yaml.scalar"
|
||||
CodeYAMLDepthLimit Code = "yaml.depth_limit"
|
||||
CodeYAMLNodeLimit Code = "yaml.node_limit"
|
||||
|
||||
CodeSchemaRoot Code = "schema.root"
|
||||
CodeSchemaUnsupportedType Code = "schema.unsupported_type"
|
||||
CodeSchemaAmbiguousField Code = "schema.ambiguous_field"
|
||||
CodeSchemaTag Code = "schema.tag"
|
||||
CodeSchemaTextCodec Code = "schema.text_codec"
|
||||
CodeSchemaInline Code = "schema.inline"
|
||||
)
|
||||
|
||||
// Location identifies a position in a configuration source. Zero line and column
|
||||
|
||||
407
schema.go
Normal file
407
schema.go
Normal file
@@ -0,0 +1,407 @@
|
||||
package configkit
|
||||
|
||||
import (
|
||||
"encoding"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type descriptorKind uint8
|
||||
|
||||
const (
|
||||
descriptorInvalid descriptorKind = iota
|
||||
descriptorStruct
|
||||
descriptorMap
|
||||
descriptorSlice
|
||||
descriptorArray
|
||||
descriptorPointer
|
||||
descriptorDynamic
|
||||
descriptorString
|
||||
descriptorBoolean
|
||||
descriptorSigned
|
||||
descriptorUnsigned
|
||||
descriptorFloat
|
||||
descriptorDuration
|
||||
descriptorText
|
||||
)
|
||||
|
||||
type schemaDescriptor struct {
|
||||
root *typeDescriptor
|
||||
}
|
||||
|
||||
type typeDescriptor struct {
|
||||
typeOf reflect.Type
|
||||
kind descriptorKind
|
||||
element *typeDescriptor
|
||||
length int
|
||||
fields []fieldDescriptor
|
||||
fieldByName map[string]int
|
||||
inlineMap *fieldDescriptor
|
||||
}
|
||||
|
||||
type fieldDescriptor struct {
|
||||
name string
|
||||
index []int
|
||||
descriptor *typeDescriptor
|
||||
secret bool
|
||||
inlineMap bool
|
||||
}
|
||||
|
||||
type schemaIssue struct {
|
||||
code Code
|
||||
path Path
|
||||
message string
|
||||
}
|
||||
|
||||
var (
|
||||
durationType = reflect.TypeFor[time.Duration]()
|
||||
textMarshalerType = reflect.TypeFor[encoding.TextMarshaler]()
|
||||
textUnmarshalerType = reflect.TypeFor[encoding.TextUnmarshaler]()
|
||||
)
|
||||
|
||||
func buildSchema(root reflect.Type) (*schemaDescriptor, *schemaIssue) {
|
||||
if root == nil || (root.Kind() != reflect.Struct && !isStringMap(root)) {
|
||||
return nil, &schemaIssue{code: CodeSchemaRoot, message: "configuration root must be a struct or string-keyed map"}
|
||||
}
|
||||
builder := schemaBuilder{
|
||||
cache: make(map[reflect.Type]*typeDescriptor),
|
||||
building: make(map[reflect.Type]bool),
|
||||
}
|
||||
descriptor, issue := builder.describe(root, Path{})
|
||||
if issue != nil {
|
||||
return nil, issue
|
||||
}
|
||||
if descriptor.kind != descriptorStruct && descriptor.kind != descriptorMap {
|
||||
return nil, &schemaIssue{code: CodeSchemaRoot, message: "configuration root must be a structural struct or string-keyed map"}
|
||||
}
|
||||
return &schemaDescriptor{root: descriptor}, nil
|
||||
}
|
||||
|
||||
type schemaBuilder struct {
|
||||
cache map[reflect.Type]*typeDescriptor
|
||||
building map[reflect.Type]bool
|
||||
}
|
||||
|
||||
func (b *schemaBuilder) describe(current reflect.Type, path Path) (*typeDescriptor, *schemaIssue) {
|
||||
if descriptor, ok := b.cache[current]; ok {
|
||||
return descriptor, nil
|
||||
}
|
||||
descriptor := &typeDescriptor{typeOf: current}
|
||||
b.cache[current] = descriptor
|
||||
b.building[current] = true
|
||||
defer delete(b.building, current)
|
||||
|
||||
fail := func(code Code, message string) (*typeDescriptor, *schemaIssue) {
|
||||
delete(b.cache, current)
|
||||
return nil, &schemaIssue{code: code, path: path.clone(), message: message}
|
||||
}
|
||||
|
||||
if current.Kind() == reflect.Pointer {
|
||||
descriptor.kind = descriptorPointer
|
||||
element, issue := b.describe(current.Elem(), path)
|
||||
if issue != nil {
|
||||
return nil, issue
|
||||
}
|
||||
descriptor.element = element
|
||||
return descriptor, nil
|
||||
}
|
||||
if current == durationType {
|
||||
descriptor.kind = descriptorDuration
|
||||
return descriptor, nil
|
||||
}
|
||||
marshals, unmarshals := textCodecSupport(current)
|
||||
if marshals || unmarshals {
|
||||
if !marshals || !unmarshals {
|
||||
return fail(CodeSchemaTextCodec, "custom text scalar must implement both text marshal and unmarshal")
|
||||
}
|
||||
descriptor.kind = descriptorText
|
||||
return descriptor, nil
|
||||
}
|
||||
|
||||
switch current.Kind() {
|
||||
case reflect.Struct:
|
||||
descriptor.kind = descriptorStruct
|
||||
descriptor.fieldByName = make(map[string]int)
|
||||
if issue := b.describeFields(descriptor, current, path); issue != nil {
|
||||
delete(b.cache, current)
|
||||
return nil, issue
|
||||
}
|
||||
case reflect.Map:
|
||||
if current.Key().Kind() != reflect.String {
|
||||
return fail(CodeSchemaUnsupportedType, "configuration maps must have string keys")
|
||||
}
|
||||
descriptor.kind = descriptorMap
|
||||
element, issue := b.describe(current.Elem(), path)
|
||||
if issue != nil {
|
||||
return nil, issue
|
||||
}
|
||||
descriptor.element = element
|
||||
case reflect.Slice:
|
||||
descriptor.kind = descriptorSlice
|
||||
element, issue := b.describe(current.Elem(), path.Index(0))
|
||||
if issue != nil {
|
||||
return nil, issue
|
||||
}
|
||||
descriptor.element = element
|
||||
case reflect.Array:
|
||||
descriptor.kind = descriptorArray
|
||||
descriptor.length = current.Len()
|
||||
element, issue := b.describe(current.Elem(), path.Index(0))
|
||||
if issue != nil {
|
||||
return nil, issue
|
||||
}
|
||||
descriptor.element = element
|
||||
case reflect.Interface:
|
||||
if current.NumMethod() != 0 {
|
||||
return fail(CodeSchemaUnsupportedType, "only the empty interface is supported for dynamic values")
|
||||
}
|
||||
descriptor.kind = descriptorDynamic
|
||||
case reflect.String:
|
||||
descriptor.kind = descriptorString
|
||||
case reflect.Bool:
|
||||
descriptor.kind = descriptorBoolean
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
descriptor.kind = descriptorSigned
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
descriptor.kind = descriptorUnsigned
|
||||
case reflect.Float32, reflect.Float64:
|
||||
descriptor.kind = descriptorFloat
|
||||
default:
|
||||
return fail(CodeSchemaUnsupportedType, "configuration type is not supported")
|
||||
}
|
||||
return descriptor, nil
|
||||
}
|
||||
|
||||
func (b *schemaBuilder) describeFields(descriptor *typeDescriptor, current reflect.Type, path Path) *schemaIssue {
|
||||
for i := 0; i < current.NumField(); i++ {
|
||||
field := current.Field(i)
|
||||
if !field.IsExported() {
|
||||
continue
|
||||
}
|
||||
name, inline, ignored, issue := parseYAMLField(field, path)
|
||||
if issue != nil {
|
||||
return issue
|
||||
}
|
||||
if ignored {
|
||||
continue
|
||||
}
|
||||
secret, issue := parseConfigKitField(field, path)
|
||||
if issue != nil {
|
||||
return issue
|
||||
}
|
||||
|
||||
if inline {
|
||||
if name != "" {
|
||||
return &schemaIssue{code: CodeSchemaTag, path: path.clone(), message: "inline field cannot declare a YAML name"}
|
||||
}
|
||||
inlineType := field.Type
|
||||
for inlineType.Kind() == reflect.Pointer {
|
||||
inlineType = inlineType.Elem()
|
||||
}
|
||||
switch inlineType.Kind() {
|
||||
case reflect.Struct:
|
||||
if b.building[inlineType] {
|
||||
return &schemaIssue{code: CodeSchemaInline, path: path.clone(), message: "recursive inline struct is not supported"}
|
||||
}
|
||||
child, childIssue := b.describe(inlineType, path)
|
||||
if childIssue != nil {
|
||||
return childIssue
|
||||
}
|
||||
if child.kind != descriptorStruct {
|
||||
return &schemaIssue{code: CodeSchemaInline, path: path.clone(), message: "inline field must be a structural struct or string-keyed map"}
|
||||
}
|
||||
for _, nested := range child.fields {
|
||||
nested.index = prependIndex(i, nested.index)
|
||||
nested.secret = secret || nested.secret
|
||||
if issue := addField(descriptor, nested, path); issue != nil {
|
||||
return issue
|
||||
}
|
||||
}
|
||||
if child.inlineMap != nil {
|
||||
nested := *child.inlineMap
|
||||
nested.index = prependIndex(i, nested.index)
|
||||
nested.secret = secret || nested.secret
|
||||
if descriptor.inlineMap != nil {
|
||||
return &schemaIssue{code: CodeSchemaInline, path: path.clone(), message: "configuration object has more than one inline map"}
|
||||
}
|
||||
descriptor.inlineMap = &nested
|
||||
}
|
||||
case reflect.Map:
|
||||
if inlineType.Key().Kind() != reflect.String {
|
||||
return &schemaIssue{code: CodeSchemaInline, path: path.clone(), message: "inline map must have string keys"}
|
||||
}
|
||||
if descriptor.inlineMap != nil {
|
||||
return &schemaIssue{code: CodeSchemaInline, path: path.clone(), message: "configuration object has more than one inline map"}
|
||||
}
|
||||
fieldPath := path.Key("*")
|
||||
fieldType, childIssue := b.describe(inlineType, fieldPath)
|
||||
if childIssue != nil {
|
||||
return childIssue
|
||||
}
|
||||
descriptor.inlineMap = &fieldDescriptor{index: []int{i}, descriptor: fieldType, secret: secret, inlineMap: true}
|
||||
default:
|
||||
return &schemaIssue{code: CodeSchemaInline, path: path.clone(), message: "inline field must be a struct, pointer to struct, or string-keyed map"}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
fieldPath := path.Key(name)
|
||||
fieldType, childIssue := b.describe(field.Type, fieldPath)
|
||||
if childIssue != nil {
|
||||
return childIssue
|
||||
}
|
||||
if issue := addField(descriptor, fieldDescriptor{name: name, index: []int{i}, descriptor: fieldType, secret: secret}, path); issue != nil {
|
||||
return issue
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func addField(owner *typeDescriptor, field fieldDescriptor, path Path) *schemaIssue {
|
||||
if _, exists := owner.fieldByName[field.name]; exists {
|
||||
return &schemaIssue{code: CodeSchemaAmbiguousField, path: path.Key(field.name), message: "configuration field name is ambiguous"}
|
||||
}
|
||||
owner.fieldByName[field.name] = len(owner.fields)
|
||||
owner.fields = append(owner.fields, field)
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseYAMLField(field reflect.StructField, path Path) (name string, inline, ignored bool, issue *schemaIssue) {
|
||||
name = strings.ToLower(field.Name)
|
||||
tag, present := field.Tag.Lookup("yaml")
|
||||
if !present {
|
||||
return name, false, false, nil
|
||||
}
|
||||
parts := strings.Split(tag, ",")
|
||||
if parts[0] == "-" {
|
||||
if len(parts) != 1 {
|
||||
return "", false, false, &schemaIssue{code: CodeSchemaTag, path: path.clone(), message: "ignored YAML field cannot declare options"}
|
||||
}
|
||||
return "", false, true, nil
|
||||
}
|
||||
if parts[0] != "" {
|
||||
name = parts[0]
|
||||
}
|
||||
seen := make(map[string]bool, len(parts)-1)
|
||||
for _, option := range parts[1:] {
|
||||
if option == "" || seen[option] {
|
||||
return "", false, false, &schemaIssue{code: CodeSchemaTag, path: path.Key(name), message: "YAML field tag is malformed"}
|
||||
}
|
||||
seen[option] = true
|
||||
switch option {
|
||||
case "inline":
|
||||
inline = true
|
||||
case "omitempty":
|
||||
// ConfigKit accepts the spelling but never omits projected values.
|
||||
default:
|
||||
return "", false, false, &schemaIssue{code: CodeSchemaTag, path: path.Key(name), message: "YAML field tag option is not supported"}
|
||||
}
|
||||
}
|
||||
if inline && parts[0] == "" {
|
||||
name = ""
|
||||
}
|
||||
return name, inline, false, nil
|
||||
}
|
||||
|
||||
func parseConfigKitField(field reflect.StructField, path Path) (bool, *schemaIssue) {
|
||||
tag, present := field.Tag.Lookup("configkit")
|
||||
if !present || tag == "" {
|
||||
return false, nil
|
||||
}
|
||||
if tag != "secret" {
|
||||
return false, &schemaIssue{code: CodeSchemaTag, path: path.clone(), message: "ConfigKit field tag is not supported"}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func textCodecSupport(current reflect.Type) (marshals, unmarshals bool) {
|
||||
marshals = current.Implements(textMarshalerType)
|
||||
unmarshals = current.Implements(textUnmarshalerType)
|
||||
if current.Kind() != reflect.Pointer {
|
||||
pointer := reflect.PointerTo(current)
|
||||
marshals = marshals || pointer.Implements(textMarshalerType)
|
||||
unmarshals = unmarshals || pointer.Implements(textUnmarshalerType)
|
||||
}
|
||||
return marshals, unmarshals
|
||||
}
|
||||
|
||||
func isStringMap(current reflect.Type) bool {
|
||||
return current.Kind() == reflect.Map && current.Key().Kind() == reflect.String
|
||||
}
|
||||
|
||||
func prependIndex(index int, nested []int) []int {
|
||||
result := make([]int, 1, len(nested)+1)
|
||||
result[0] = index
|
||||
return append(result, nested...)
|
||||
}
|
||||
|
||||
type schemaPath struct {
|
||||
descriptor *typeDescriptor
|
||||
field *fieldDescriptor
|
||||
dynamic bool
|
||||
}
|
||||
|
||||
func resolveSchemaPath(schema *schemaDescriptor, path Path) (schemaPath, bool) {
|
||||
if schema == nil || schema.root == nil || !path.valid() {
|
||||
return schemaPath{}, false
|
||||
}
|
||||
current := schema.root
|
||||
var field *fieldDescriptor
|
||||
for _, segment := range path.segments {
|
||||
for current.kind == descriptorPointer {
|
||||
current = current.element
|
||||
}
|
||||
switch current.kind {
|
||||
case descriptorStruct:
|
||||
if segment.kind == pathSegmentIndex {
|
||||
return schemaPath{}, false
|
||||
}
|
||||
position, ok := current.fieldByName[segment.key]
|
||||
if ok {
|
||||
field = ¤t.fields[position]
|
||||
current = field.descriptor
|
||||
continue
|
||||
}
|
||||
if current.inlineMap != nil {
|
||||
field = current.inlineMap
|
||||
current = current.inlineMap.descriptor.element
|
||||
continue
|
||||
}
|
||||
return schemaPath{}, false
|
||||
case descriptorMap:
|
||||
if segment.kind == pathSegmentIndex {
|
||||
return schemaPath{}, false
|
||||
}
|
||||
field = nil
|
||||
current = current.element
|
||||
case descriptorSlice, descriptorArray:
|
||||
index, ok := schemaIndex(segment)
|
||||
if !ok || (current.kind == descriptorArray && index >= current.length) {
|
||||
return schemaPath{}, false
|
||||
}
|
||||
field = nil
|
||||
current = current.element
|
||||
case descriptorDynamic:
|
||||
return schemaPath{descriptor: current, field: field, dynamic: true}, true
|
||||
default:
|
||||
return schemaPath{}, false
|
||||
}
|
||||
}
|
||||
for current.kind == descriptorPointer {
|
||||
current = current.element
|
||||
}
|
||||
return schemaPath{descriptor: current, field: field, dynamic: current.kind == descriptorDynamic}, true
|
||||
}
|
||||
|
||||
func schemaIndex(segment pathSegment) (int, bool) {
|
||||
if segment.kind == pathSegmentIndex {
|
||||
return segment.index, segment.index >= 0
|
||||
}
|
||||
if segment.kind != pathSegmentParsed || segment.key == "" || (len(segment.key) > 1 && segment.key[0] == '0') {
|
||||
return 0, false
|
||||
}
|
||||
index, err := strconv.Atoi(segment.key)
|
||||
return index, err == nil && index >= 0
|
||||
}
|
||||
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