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 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]() 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 } 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 = ¤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 }