feat: unify generated configuration loading and extensible decoders
This commit is contained in:
320
config/bind.go
Normal file
320
config/bind.go
Normal file
@@ -0,0 +1,320 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var jsonUnmarshalerType = reflect.TypeOf((*json.Unmarshaler)(nil)).Elem()
|
||||
|
||||
type documentBinder struct {
|
||||
doc Document
|
||||
nodes int
|
||||
}
|
||||
|
||||
func (b *documentBinder) step(path string, depth int) error {
|
||||
b.nodes++
|
||||
if depth > maxConfigDepth || b.nodes > maxConfigNodes {
|
||||
return b.doc.diagnostic(path, "configuration nesting or node limit exceeded", nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func bindDocument[T any](doc Document, defaults T) (T, error) {
|
||||
var zero T
|
||||
base := reflect.ValueOf(&defaults).Elem()
|
||||
if base.Kind() != reflect.Struct && (base.Kind() != reflect.Pointer || base.Type().Elem().Kind() != reflect.Struct) {
|
||||
return zero, &Error{Reason: "project configuration must be a struct"}
|
||||
}
|
||||
result, err := cloneConfiguration(base, 0)
|
||||
if err != nil {
|
||||
return zero, &Error{Reason: "cannot clone configuration defaults", cause: err}
|
||||
}
|
||||
b := documentBinder{doc: doc}
|
||||
if err = b.bind(doc.Value, result, "", 0, false); err != nil {
|
||||
return zero, err
|
||||
}
|
||||
return result.Interface().(T), nil
|
||||
}
|
||||
|
||||
func (b *documentBinder) decode(v any, dst reflect.Value, path string) (err error) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
err = b.doc.diagnostic(path, "custom value decoder panicked", nil)
|
||||
}
|
||||
}()
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return b.doc.diagnostic(path, "unsupported configuration value", err)
|
||||
}
|
||||
if err = json.Unmarshal(raw, dst.Addr().Interface()); err != nil {
|
||||
return b.doc.diagnostic(path, "expected a valid "+dst.Type().String(), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (b *documentBinder) bind(v any, dst reflect.Value, path string, depth int, quoted bool) error {
|
||||
if err := b.step(path, depth); err != nil {
|
||||
return err
|
||||
}
|
||||
if v == nil {
|
||||
return b.decode(nil, dst, path)
|
||||
} // Preserve encoding/json null semantics.
|
||||
if dst.Kind() == reflect.Pointer {
|
||||
if dst.IsNil() {
|
||||
dst.Set(reflect.New(dst.Type().Elem()))
|
||||
}
|
||||
return b.bind(v, dst.Elem(), path, depth+1, quoted)
|
||||
}
|
||||
if dst.Type() == durationType {
|
||||
var raw string
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
raw = x
|
||||
case Text:
|
||||
raw = string(x)
|
||||
default:
|
||||
return b.decode(v, dst, path)
|
||||
}
|
||||
if err := parseEnvValue(dst, strings.TrimSpace(raw)); err != nil {
|
||||
return b.doc.diagnostic(path, "expected a duration such as 5s", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if dst.CanAddr() && dst.Addr().Type().Implements(jsonUnmarshalerType) {
|
||||
normalized, err := b.dynamic(v, path, depth+1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return b.decode(normalized, dst, path)
|
||||
}
|
||||
if dst.CanAddr() && dst.Addr().Type().Implements(textType) {
|
||||
if text, ok := v.(Text); ok {
|
||||
v = string(text)
|
||||
}
|
||||
return b.decode(v, dst, path)
|
||||
}
|
||||
if text, ok := v.(Text); ok {
|
||||
if dst.Kind() == reflect.String || dst.Kind() == reflect.Interface || (dst.Kind() == reflect.Slice && dst.Type().Elem().Kind() == reflect.Uint8) {
|
||||
return b.decode(string(text), dst, path)
|
||||
}
|
||||
switch dst.Kind() {
|
||||
case reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Float32, reflect.Float64:
|
||||
if err := parseEnvValue(dst, strings.TrimSpace(string(text))); err != nil {
|
||||
return b.doc.diagnostic(path, "expected a valid "+dst.Type().String(), err)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return b.doc.diagnostic(path, "expected an object or array, not scalar text", nil)
|
||||
}
|
||||
}
|
||||
if quoted {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return b.doc.diagnostic(path, "expected a JSON-encoded string", nil)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(s), dst.Addr().Interface()); err != nil {
|
||||
return b.doc.diagnostic(path, "invalid string-encoded value", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
switch dst.Kind() {
|
||||
case reflect.Struct:
|
||||
m, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
return b.doc.diagnostic(path, "expected an object", nil)
|
||||
}
|
||||
fields := configurationFields(dst.Type())
|
||||
used := map[string]bool{}
|
||||
for _, name := range sortedKeys(m) {
|
||||
child := joinField(path, name)
|
||||
f, ok := fields[name]
|
||||
canonical := name
|
||||
if !ok {
|
||||
for n, field := range fields {
|
||||
if strings.EqualFold(n, name) {
|
||||
if ok {
|
||||
return b.doc.diagnostic(child, "ambiguous field name", nil)
|
||||
}
|
||||
f, ok, canonical = field, true, n
|
||||
}
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
return b.doc.diagnostic(child, "unknown field", nil)
|
||||
}
|
||||
if used[canonical] {
|
||||
return b.doc.diagnostic(child, "duplicate field binding", nil)
|
||||
}
|
||||
used[canonical] = true
|
||||
target := dst
|
||||
for _, index := range f.Index {
|
||||
if target.Kind() == reflect.Pointer {
|
||||
if target.IsNil() {
|
||||
if !target.CanSet() {
|
||||
return b.doc.diagnostic(child, "field cannot be assigned", nil)
|
||||
}
|
||||
target.Set(reflect.New(target.Type().Elem()))
|
||||
}
|
||||
target = target.Elem()
|
||||
}
|
||||
target = target.Field(index)
|
||||
}
|
||||
if !target.CanSet() {
|
||||
return b.doc.diagnostic(child, "field cannot be assigned", nil)
|
||||
}
|
||||
if err := b.bind(m[name], target, child, depth+1, strings.Contains(","+f.Tag.Get("json")+",", ",string,")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case reflect.Map:
|
||||
m, ok := v.(map[string]any)
|
||||
if !ok || dst.Type().Key().Kind() != reflect.String {
|
||||
return b.doc.diagnostic(path, "expected an object with string keys", nil)
|
||||
}
|
||||
if dst.IsNil() {
|
||||
dst.Set(reflect.MakeMap(dst.Type()))
|
||||
}
|
||||
for _, key := range sortedKeys(m) {
|
||||
value := reflect.New(dst.Type().Elem()).Elem()
|
||||
if err := b.bind(m[key], value, joinField(path, key), depth+1, false); err != nil {
|
||||
return err
|
||||
}
|
||||
k := reflect.New(dst.Type().Key()).Elem()
|
||||
k.SetString(key)
|
||||
dst.SetMapIndex(k, value)
|
||||
}
|
||||
return nil
|
||||
case reflect.Slice, reflect.Array:
|
||||
if _, ok := v.(string); ok && dst.Kind() == reflect.Slice && dst.Type().Elem().Kind() == reflect.Uint8 {
|
||||
return b.decode(v, dst, path)
|
||||
}
|
||||
items, ok := v.([]any)
|
||||
if !ok {
|
||||
return b.doc.diagnostic(path, "expected an array", nil)
|
||||
}
|
||||
if dst.Kind() == reflect.Array && len(items) != dst.Len() {
|
||||
return b.doc.diagnostic(path, "array length does not match its declared type", nil)
|
||||
}
|
||||
if dst.Kind() == reflect.Slice {
|
||||
dst.Set(reflect.MakeSlice(dst.Type(), len(items), len(items)))
|
||||
}
|
||||
for i, value := range items {
|
||||
if err := b.bind(value, dst.Index(i), fmt.Sprintf("%s[%d]", path, i), depth+1, false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case reflect.Interface:
|
||||
v, err := b.dynamic(v, path, depth+1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dst.Type().NumMethod() == 0 {
|
||||
dst.Set(reflect.ValueOf(v))
|
||||
return nil
|
||||
}
|
||||
return b.decode(v, dst, path)
|
||||
default:
|
||||
return b.decode(v, dst, path)
|
||||
}
|
||||
}
|
||||
func sortedKeys(m map[string]any) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
func (b *documentBinder) dynamic(v any, path string, depth int) (any, error) {
|
||||
if err := b.step(path, depth); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case Text:
|
||||
return string(x), nil
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(x))
|
||||
for _, k := range sortedKeys(x) {
|
||||
c, err := b.dynamic(x[k], joinField(path, k), depth+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[k] = c
|
||||
}
|
||||
return out, nil
|
||||
case []any:
|
||||
out := make([]any, len(x))
|
||||
for i, v := range x {
|
||||
c, err := b.dynamic(v, fmt.Sprintf("%s[%d]", path, i), depth+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[i] = c
|
||||
}
|
||||
return out, nil
|
||||
default:
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Match encoding/json's field promotion: the shallowest declaration wins;
|
||||
// explicit tags win ties, and ambiguous fields remain unavailable.
|
||||
func configurationFields(t reflect.Type) map[string]reflect.StructField {
|
||||
type candidate struct {
|
||||
field reflect.StructField
|
||||
tagged bool
|
||||
}
|
||||
candidates := map[string][]candidate{}
|
||||
var visit func(reflect.Type, []int, map[reflect.Type]bool)
|
||||
visit = func(current reflect.Type, prefix []int, visiting map[reflect.Type]bool) {
|
||||
if visiting[current] {
|
||||
return
|
||||
}
|
||||
visiting[current] = true
|
||||
defer delete(visiting, current)
|
||||
for i := 0; i < current.NumField(); i++ {
|
||||
f := current.Field(i)
|
||||
name := strings.Split(f.Tag.Get("json"), ",")[0]
|
||||
if name == "-" {
|
||||
continue
|
||||
}
|
||||
child := f.Type
|
||||
if child.Kind() == reflect.Pointer {
|
||||
child = child.Elem()
|
||||
}
|
||||
if f.PkgPath != "" && (!f.Anonymous || child.Kind() != reflect.Struct) {
|
||||
continue
|
||||
}
|
||||
f.Index = append(append([]int(nil), prefix...), i)
|
||||
if name == "" && f.Anonymous && child.Kind() == reflect.Struct {
|
||||
visit(child, f.Index, visiting)
|
||||
continue
|
||||
}
|
||||
tagged := name != ""
|
||||
if name == "" {
|
||||
name = f.Name
|
||||
}
|
||||
candidates[name] = append(candidates[name], candidate{f, tagged})
|
||||
}
|
||||
}
|
||||
visit(t, nil, map[reflect.Type]bool{})
|
||||
fields := map[string]reflect.StructField{}
|
||||
for name, list := range candidates {
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
if len(list[i].field.Index) != len(list[j].field.Index) {
|
||||
return len(list[i].field.Index) < len(list[j].field.Index)
|
||||
}
|
||||
return list[i].tagged && !list[j].tagged
|
||||
})
|
||||
if len(list) > 1 && len(list[0].field.Index) == len(list[1].field.Index) && list[0].tagged == list[1].tagged {
|
||||
continue
|
||||
}
|
||||
fields[name] = list[0].field
|
||||
}
|
||||
return fields
|
||||
}
|
||||
Reference in New Issue
Block a user