Build reusable schema descriptors
This commit is contained in:
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
|
||||
}
|
||||
Reference in New Issue
Block a user