Files
ConfigKit/schema.go

431 lines
13 KiB
Go

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
descriptorSecret
descriptorText
)
type schemaDescriptor struct {
root *typeDescriptor
types map[reflect.Type]*typeDescriptor
}
type typeDescriptor struct {
typeOf reflect.Type
kind descriptorKind
element *typeDescriptor
length int
members []structMember
fields []fieldDescriptor
fieldByName map[string]int
inlineMap *fieldDescriptor
}
type structMember struct {
index int
name string
descriptor *typeDescriptor
inline bool
}
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]()
secretType = reflect.TypeFor[Secret]()
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, types: builder.cache}, 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
}
if current == secretType {
descriptor.kind = descriptorSecret
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
}
memberType, memberIssue := b.describe(field.Type, path)
if memberIssue != nil {
return memberIssue
}
descriptor.members = append(descriptor.members, structMember{index: i, descriptor: memberType, inline: true})
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}
descriptor.members = append(descriptor.members, structMember{index: i, descriptor: fieldType, inline: 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
}
descriptor.members = append(descriptor.members, structMember{index: i, name: name, descriptor: fieldType})
}
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 = &current.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
}
}
dynamic := current
for dynamic.kind == descriptorPointer {
dynamic = dynamic.element
}
return schemaPath{descriptor: current, field: field, dynamic: dynamic.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
}