Files
ConfigKit/internal/engine/schema.go

410 lines
12 KiB
Go

package engine
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
secrets SecretBinding
}
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
}
// SecretState is the private operation state for one exact-type secret value.
// It is supplied by the facade and never enters inspection records.
type SecretState struct {
Name string
Value string
Specified bool
Resolved bool
}
// SecretBinding adapts the facade-owned exact secret type for one load.
// Bindings are operation data: callers must supply the type and conversions
// together, and a binding never becomes package-global state.
type SecretBinding struct {
Type reflect.Type
Read func(reflect.Value) SecretState
Make func(SecretState) reflect.Value
}
var (
durationType = reflect.TypeFor[time.Duration]()
textMarshalerType = reflect.TypeFor[encoding.TextMarshaler]()
textUnmarshalerType = reflect.TypeFor[encoding.TextUnmarshaler]()
)
func buildSchema(root reflect.Type, secrets SecretBinding) (*schemaDescriptor, *schemaIssue) {
secrets.validate()
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),
secrets: secrets,
}
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, secrets: secrets}, nil
}
type schemaBuilder struct {
cache map[reflect.Type]*typeDescriptor
building map[reflect.Type]bool
secrets SecretBinding
}
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 b.secrets.Type != nil && current == b.secrets.Type {
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 (binding SecretBinding) validate() {
if binding.Type == nil {
return
}
if binding.Read == nil || binding.Make == nil {
panic("configkit engine: incomplete secret binding")
}
}
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"}
}
if issue := b.describeInlineField(descriptor, field, i, secret, path); issue != nil {
return issue
}
continue
}
if issue := b.describeOrdinaryField(descriptor, field, i, name, 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
}
// terminalDescriptor follows a pointer-only descriptor chain without entering
// any value. Recursive pointer aliases can refer back to an earlier descriptor,
// so callers must handle a repeated descriptor as an unaddressable chain.
func terminalDescriptor(descriptor *typeDescriptor) (*typeDescriptor, bool) {
visited := make(map[*typeDescriptor]struct{})
for descriptor != nil && descriptor.kind == descriptorPointer {
if _, exists := visited[descriptor]; exists {
return nil, false
}
visited[descriptor] = struct{}{}
descriptor = descriptor.element
}
return descriptor, descriptor != nil
}
func resolveSchemaPath(schema *schemaDescriptor, path Path) (schemaPath, bool) {
if schema == nil || schema.root == nil || !path.valid() {
return schemaPath{}, false
}
current := schema.root
for _, segment := range path.segments {
var ok bool
current, ok = terminalDescriptor(current)
if !ok {
return schemaPath{}, false
}
switch current.kind {
case descriptorStruct:
if segment.kind == pathSegmentIndex {
return schemaPath{}, false
}
position, ok := current.fieldByName[segment.key]
if ok {
current = current.fields[position].descriptor
continue
}
if current.inlineMap != nil {
current = current.inlineMap.descriptor.element
continue
}
return schemaPath{}, false
case descriptorMap:
if segment.kind == pathSegmentIndex {
return schemaPath{}, false
}
current = current.element
case descriptorSlice, descriptorArray:
index, ok := schemaIndex(segment)
if !ok || (current.kind == descriptorArray && index >= current.length) {
return schemaPath{}, false
}
current = current.element
case descriptorDynamic:
return schemaPath{descriptor: current}, true
default:
return schemaPath{}, false
}
}
return schemaPath{descriptor: current}, 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 && strconv.Itoa(index) == segment.key
}