feat: unify generated configuration loading and extensible decoders

This commit is contained in:
2026-09-26 14:47:33 +08:00
parent 91a6beb2f3
commit c60841f901
42 changed files with 2280 additions and 189 deletions

320
config/bind.go Normal file
View 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
}

View File

@@ -62,19 +62,19 @@ func Default() Config {
// Validate checks configuration values before an application starts.
func (c Config) Validate() error {
if err := c.Lifecycle.Validate(); err != nil {
return err
return AtField("lifecycle", err)
}
if err := c.Request.Validate(); err != nil {
return err
return AtField("request", err)
}
if err := c.Log.Validate(); err != nil {
return err
return AtField("log", err)
}
if err := c.Endpoints.Validate(); err != nil {
return err
}
if err := c.Errors.Validate(); err != nil {
return err
return AtField("errors", err)
}
encoder := c.ResponseEncoder
if encoder == nil {
@@ -90,7 +90,7 @@ func (c Config) Validate() error {
return err
}
if err := c.Response.Validate(); err != nil {
return err
return AtField("response", err)
}
if c.SlowRequestThreshold < 0 {
return &ConfigError{Field: "slowRequestThreshold", Message: "must be nonnegative"}
@@ -99,13 +99,13 @@ func (c Config) Validate() error {
return &ConfigError{Field: "address", Message: "is required"}
}
if strings.TrimSpace(c.RequestIDHeader) == "" {
return &ConfigError{Field: "request_id_header", Message: "is required"}
return &ConfigError{Field: "requestIdHeader", Message: "is required"}
}
if c.ShutdownTimeout <= 0 {
return &ConfigError{Field: "shutdown_timeout", Message: "must be positive"}
return &ConfigError{Field: "shutdownTimeout", Message: "must be positive"}
}
if _, err := httpx.NewClientIPResolver(c.TrustedProxies...); err != nil {
return &ConfigError{Field: "trusted_proxies", Message: err.Error()}
return &Error{Field: "trustedProxies", Reason: "expected IP addresses or CIDRs", cause: err}
}
return c.ValidateTransport()
}

View File

@@ -112,7 +112,7 @@ func TestLoadFromEnvRejectsInvalidValues(t *testing.T) {
t.Setenv("GO_OMNI_SHUTDOWN_TIMEOUT", "1s")
t.Setenv("GO_OMNI_TRUSTED_PROXIES", "proxy.example.com")
if _, err := LoadFromEnv(""); err == nil || !strings.Contains(err.Error(), "trusted_proxies") {
if _, err := LoadFromEnv(""); err == nil || !strings.Contains(err.Error(), "trustedProxies") {
t.Fatalf("expected invalid trusted proxy error, got %v", err)
}
t.Setenv("GO_OMNI_TRUSTED_PROXIES", "")
@@ -123,7 +123,7 @@ func TestLoadFromEnvRejectsInvalidValues(t *testing.T) {
}
cfg = Default()
cfg.RequestIDHeader = " "
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "request_id_header") {
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "requestIdHeader") {
t.Fatalf("expected invalid request header error, got %v", err)
}
}

398
config/decoders.go Normal file
View File

@@ -0,0 +1,398 @@
package config
import (
"bytes"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"
"regexp"
"sort"
"strconv"
"strings"
"unicode/utf8"
"gopkg.in/yaml.v3"
)
type JSONDecoder struct{}
type YAMLDecoder struct{}
type XMLDecoder struct{}
type parseState struct {
doc Document
nodes int
}
func newParseState() *parseState { return &parseState{doc: Document{Positions: map[string]Position{}}} }
func (p *parseState) enter(path string, pos Position, depth int) error {
p.nodes++
p.doc.Positions[path] = pos
if depth > maxConfigDepth || p.nodes > maxConfigNodes {
return p.doc.diagnostic(path, "configuration nesting or node limit exceeded", nil)
}
return nil
}
type sourcePositions struct{ starts []int }
func newSourcePositions(data []byte) sourcePositions {
p := sourcePositions{starts: []int{0}}
for i, c := range data {
if c == '\n' {
p.starts = append(p.starts, i+1)
}
}
return p
}
func (p sourcePositions) at(offset int64) Position {
if offset < 0 {
offset = 0
}
i := sort.Search(len(p.starts), func(i int) bool { return int64(p.starts[i]) > offset }) - 1
return Position{Line: i + 1, Column: int(offset) - p.starts[i] + 1}
}
var yamlLine = regexp.MustCompile(`line ([0-9]+)`)
func yamlSyntax(err error) *Error {
d := &Error{Reason: "invalid YAML syntax", cause: err}
if m := yamlLine.FindStringSubmatch(err.Error()); len(m) == 2 {
d.Line, _ = strconv.Atoi(m[1])
d.Column = 1
}
return d
}
func (JSONDecoder) Decode(data []byte) (Document, error) {
p := newParseState()
if len(data) > maxConfigBytes {
return p.doc, &Error{Reason: "file exceeds 1 MiB"}
}
if !utf8.Valid(data) {
return p.doc, &Error{Reason: "configuration must be UTF-8"}
}
positions := newSourcePositions(data)
d := json.NewDecoder(bytes.NewReader(data))
d.UseNumber()
var parse func(string, int) (any, error)
parse = func(path string, depth int) (any, error) {
offset := d.InputOffset()
for offset < int64(len(data)) && strings.ContainsRune(" \t\r\n:,", rune(data[offset])) {
offset++
}
if err := p.enter(path, positions.at(offset), depth); err != nil {
return nil, err
}
token, err := d.Token()
if err != nil {
pos := positions.at(d.InputOffset())
var syntax *json.SyntaxError
if errors.As(err, &syntax) {
pos = positions.at(syntax.Offset - 1)
}
return nil, &Error{Field: path, Line: pos.Line, Column: pos.Column, Reason: "invalid JSON syntax", cause: err}
}
if delim, ok := token.(json.Delim); ok {
switch delim {
case '{':
out := map[string]any{}
for d.More() {
k, e := d.Token()
if e != nil {
return nil, p.doc.diagnostic(path, "invalid object key", e)
}
key, ok := k.(string)
if !ok {
return nil, p.doc.diagnostic(path, "object key must be a string", nil)
}
child := joinField(path, key)
if _, exists := out[key]; exists {
return nil, p.doc.diagnostic(child, "duplicate field", nil)
}
out[key], e = parse(child, depth+1)
if e != nil {
return nil, e
}
}
if _, err = d.Token(); err != nil {
return nil, p.doc.diagnostic(path, "unterminated object", err)
}
return out, nil
case '[':
out := []any{}
for d.More() {
v, e := parse(fmt.Sprintf("%s[%d]", path, len(out)), depth+1)
if e != nil {
return nil, e
}
out = append(out, v)
}
if _, err = d.Token(); err != nil {
return nil, p.doc.diagnostic(path, "unterminated array", err)
}
return out, nil
default:
return nil, p.doc.diagnostic(path, "unexpected JSON delimiter", nil)
}
}
return token, nil
}
var err error
p.doc.Value, err = parse("", 0)
if err == nil {
if _, e := d.Token(); e != io.EOF {
err = p.doc.diagnostic("", "exactly one JSON document required", e)
}
}
return p.doc, err
}
func (YAMLDecoder) Decode(data []byte) (Document, error) {
p := newParseState()
if len(data) > maxConfigBytes {
return p.doc, &Error{Reason: "file exceeds 1 MiB"}
}
if !utf8.Valid(data) {
return p.doc, &Error{Reason: "configuration must be UTF-8"}
}
d := yaml.NewDecoder(bytes.NewReader(data))
var root yaml.Node
if err := d.Decode(&root); err != nil {
return p.doc, yamlSyntax(err)
}
var extra yaml.Node
if err := d.Decode(&extra); err != io.EOF {
return p.doc, &Error{Reason: "exactly one YAML document required", cause: err}
}
var walk func(*yaml.Node, string, int) (any, error)
walk = func(n *yaml.Node, path string, depth int) (any, error) {
if err := p.enter(path, Position{n.Line, n.Column}, depth); err != nil {
return nil, err
}
switch n.Kind {
case yaml.MappingNode:
out := map[string]any{}
for i := 0; i < len(n.Content); i += 2 {
k, v := n.Content[i], n.Content[i+1]
if k.Tag != "!!str" {
return nil, p.doc.diagnostic(path, "mapping keys must be strings; merge keys are not supported", nil)
}
child := joinField(path, k.Value)
if _, ok := out[k.Value]; ok {
return nil, &Error{Field: child, Line: k.Line, Column: k.Column, Reason: "duplicate field"}
}
x, err := walk(v, child, depth+1)
if err != nil {
return nil, err
}
out[k.Value] = x
}
return out, nil
case yaml.SequenceNode:
out := make([]any, len(n.Content))
for i, c := range n.Content {
var err error
out[i], err = walk(c, fmt.Sprintf("%s[%d]", path, i), depth+1)
if err != nil {
return nil, err
}
}
return out, nil
case yaml.AliasNode:
return nil, p.doc.diagnostic(path, "YAML aliases are not supported", nil)
case yaml.ScalarNode:
switch n.Tag {
case "!!str", "!!timestamp":
return n.Value, nil
case "!!null":
return nil, nil
case "!!bool", "!!int", "!!float":
var v any
if err := n.Decode(&v); err != nil {
return nil, p.doc.diagnostic(path, "invalid YAML scalar", err)
}
raw, err := json.Marshal(v)
if err != nil {
return nil, p.doc.diagnostic(path, "number must be finite", err)
}
var out any
dec := json.NewDecoder(bytes.NewReader(raw))
dec.UseNumber()
if err = dec.Decode(&out); err != nil {
return nil, p.doc.diagnostic(path, "invalid YAML scalar", err)
}
return out, nil
default:
return nil, p.doc.diagnostic(path, "unsupported YAML tag", nil)
}
}
return nil, p.doc.diagnostic(path, "unsupported YAML node", nil)
}
if len(root.Content) != 1 {
return p.doc, &Error{Reason: "one nonempty YAML document required"}
}
var err error
p.doc.Value, err = walk(root.Content[0], "", 0)
return p.doc, err
}
// XML uses a config root, element field names, type="array" with item children,
// type="object" for empty objects, and type="null" for explicit null. Scalar
// text is bound using the destination type; optional number/bool/string types
// let dynamic any values preserve their intended JSON type.
func (XMLDecoder) Decode(data []byte) (Document, error) {
p := newParseState()
if len(data) > maxConfigBytes {
return p.doc, &Error{Reason: "file exceeds 1 MiB"}
}
if !utf8.Valid(data) {
return p.doc, &Error{Reason: "configuration must be UTF-8"}
}
d := xml.NewDecoder(bytes.NewReader(data))
var parse func(xml.StartElement, string, int, Position) (any, error)
parse = func(start xml.StartElement, path string, depth int, pos Position) (any, error) {
if err := p.enter(path, pos, depth); err != nil {
return nil, err
}
if start.Name.Space != "" {
return nil, p.doc.diagnostic(path, "XML namespaces are not supported", nil)
}
kind := ""
attrSeen := false
for _, a := range start.Attr {
if a.Name.Space != "" || a.Name.Local != "type" || attrSeen {
return nil, p.doc.diagnostic(path, "only one XML type attribute is supported", nil)
}
kind = a.Value
attrSeen = true
}
if depth == 0 && kind == "" {
kind = "object"
}
switch kind {
case "", "object", "array", "null", "string", "number", "bool":
default:
return nil, p.doc.diagnostic(path, "unsupported XML type", nil)
}
children := map[string]any{}
array := []any{}
var text strings.Builder
for {
line, column := d.InputPos()
token, err := d.Token()
if err != nil {
return nil, &Error{Field: path, Line: line, Column: column, Reason: "invalid XML syntax", cause: err}
}
switch x := token.(type) {
case xml.StartElement:
if kind != "" && kind != "object" && kind != "array" {
return nil, p.doc.diagnostic(path, "scalar XML element cannot contain children", nil)
}
child := joinField(path, x.Name.Local)
if kind == "array" {
if x.Name.Local != "item" {
return nil, p.doc.diagnostic(path, "XML array children must be item elements", nil)
}
child = fmt.Sprintf("%s[%d]", path, len(array))
} else if _, ok := children[x.Name.Local]; ok {
return nil, p.doc.diagnostic(child, "duplicate field; use type=\"array\" for lists", nil)
}
v, e := parse(x, child, depth+1, Position{line, column})
if e != nil {
return nil, e
}
if kind == "array" {
array = append(array, v)
} else {
children[x.Name.Local] = v
}
case xml.CharData:
text.Write([]byte(x))
case xml.Comment:
case xml.EndElement:
raw := text.String()
if len(children) > 0 || kind == "array" || kind == "object" {
if strings.TrimSpace(raw) != "" {
return nil, p.doc.diagnostic(path, "mixed XML text and elements are not supported", nil)
}
if kind == "array" {
return array, nil
}
return children, nil
}
switch kind {
case "null":
if strings.TrimSpace(raw) != "" {
return nil, p.doc.diagnostic(path, "null element must be empty", nil)
}
return nil, nil
case "string":
return raw, nil
case "number":
var n any
dec := json.NewDecoder(strings.NewReader(strings.TrimSpace(raw)))
dec.UseNumber()
if err := dec.Decode(&n); err != nil {
return nil, p.doc.diagnostic(path, "invalid number", err)
}
if _, ok := n.(json.Number); !ok {
return nil, p.doc.diagnostic(path, "expected number", nil)
}
if _, e := dec.Token(); e != io.EOF {
return nil, p.doc.diagnostic(path, "invalid number", e)
}
return n, nil
case "bool":
b, e := strconv.ParseBool(strings.TrimSpace(raw))
if e != nil {
return nil, p.doc.diagnostic(path, "expected boolean", e)
}
return b, nil
}
return Text(raw), nil
default:
return nil, p.doc.diagnostic(path, "XML directives and processing instructions are not supported", nil)
}
}
}
found := false
prolog := false
for {
line, column := d.InputPos()
token, err := d.Token()
if err == io.EOF {
break
}
if err != nil {
return p.doc, &Error{Line: line, Column: column, Reason: "invalid XML syntax", cause: err}
}
switch x := token.(type) {
case xml.StartElement:
if found || x.Name.Local != "config" {
return p.doc, &Error{Line: line, Column: column, Reason: "exactly one config root required"}
}
found = true
p.doc.Value, err = parse(x, "", 0, Position{line, column})
if err != nil {
return p.doc, err
}
case xml.CharData:
if strings.TrimSpace(string(x)) != "" {
return p.doc, &Error{Reason: "unexpected text outside XML root"}
}
case xml.Comment:
case xml.ProcInst:
if found || prolog || x.Target != "xml" {
return p.doc, &Error{Reason: "XML processing instructions are not supported"}
}
prolog = true
default:
return p.doc, &Error{Reason: "XML directives are not supported"}
}
}
if !found {
return p.doc, &Error{Reason: "config root required"}
}
return p.doc, nil
}

94
config/diagnostic.go Normal file
View File

@@ -0,0 +1,94 @@
package config
import (
"errors"
"fmt"
"strings"
)
// Error is a value-free diagnostic. Cause may contain private parser details;
// errors.Is/As remain available, but Error never formats Cause automatically.
type Error struct {
File, Field, Env, Reason string
Line, Column int
cause error
}
func (e *Error) Error() string {
where := "config"
if e.File != "" {
where = fmt.Sprintf("config %q", e.File)
}
if e.Line > 0 {
where += fmt.Sprintf(":%d:%d", e.Line, e.Column)
}
if e.Env != "" {
where += fmt.Sprintf(" env %q", e.Env)
}
if e.Field != "" {
where += fmt.Sprintf(" field %q", e.Field)
}
return where + ": " + e.Reason
}
func (e *Error) Unwrap() error { return e.cause }
// DecodeError is retained for callers of errors.As from earlier releases.
type DecodeError = Error
// Invalid reports a public, value-free validation reason for a configuration field.
func Invalid(field, reason string) error { return &ConfigError{Field: field, Message: reason} }
// AtField prefixes a nested validation error without losing its original cause.
// Unstructured validation errors are deliberately not echoed; use Invalid when
// a custom validator needs a public reason rather than a private error message.
func AtField(field string, err error) error {
if err == nil {
return nil
}
d := validationError(err)
d.Field = joinField(field, d.Field)
return d
}
func validationError(err error) *Error {
var d *Error
if errors.As(err, &d) {
out := *d
out.cause = err
return &out
}
var c *ConfigError
if errors.As(err, &c) {
return &Error{Field: c.Field, Reason: c.Message, cause: err}
}
return &Error{Reason: "validation failed", cause: err}
}
func joinField(parent, child string) string {
if parent == "" {
return child
}
if child == "" {
return parent
}
if strings.HasPrefix(child, "[") {
return parent + child
}
return parent + "." + child
}
// Position uses one-based source line/column numbers. Zero means unavailable.
type Position struct{ Line, Column int }
// Document is the common decoder result. Values use JSON-compatible data:
// string, bool, json.Number, nil, []any, map[string]any. Text is available for
// formats such as XML whose scalar tokens have no inherent JSON type.
type Document struct {
Value any
Positions map[string]Position
}
type Text string
func (d Document) diagnostic(field, reason string, cause error) *Error {
p := d.Positions[field]
return &Error{Field: field, Reason: reason, Line: p.Line, Column: p.Column, cause: cause}
}

View File

@@ -1,100 +1,16 @@
package config
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"time"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/logx"
"gopkg.in/yaml.v3"
)
// DecodeFile merges a bounded JSON/YAML document into defaults. It uses JSON
// field names for both formats, rejects unknown fields and accepts durations
// such as "3s". Application-specific validation remains with the caller.
func DecodeFile[T any](path string, defaults T) (T, error) {
result, err := decodeFile(path, defaults)
if err != nil {
var zero T
return zero, &DecodeError{cause: err}
}
return result, nil
}
// DecodeError keeps input values out of public diagnostics while preserving
// errors.Is/As for explicitly handled underlying I/O and syntax errors.
type DecodeError struct{ cause error }
func (e *DecodeError) Error() string {
return "config: cannot decode file; check its path, format, field names and value types"
}
func (e *DecodeError) Unwrap() error { return e.cause }
func decodeFile[T any](path string, defaults T) (T, error) {
var zero T
f, err := os.Open(path)
if err != nil {
return zero, err
}
defer f.Close()
data, err := io.ReadAll(io.LimitReader(f, (1<<20)+1))
if err != nil {
return zero, err
}
if len(data) > 1<<20 {
return zero, errors.New("config: file exceeds 1 MiB")
}
var value any
switch strings.ToLower(filepath.Ext(path)) {
case ".yaml", ".yml":
dec := yaml.NewDecoder(bytes.NewReader(data))
if err := dec.Decode(&value); err != nil {
return zero, err
}
var extra any
if err := dec.Decode(&extra); err != io.EOF {
return zero, errors.New("config: one YAML document required")
}
case ".json":
if err := apierrors.DecodeCatalogJSON(data, &value); err != nil {
return zero, err
}
default:
return zero, errors.New("config: expected .json, .yaml or .yml")
}
if _, ok := value.(map[string]any); !ok {
return zero, errors.New("config: root must be an object")
}
value, err = durations(value, reflect.TypeOf(defaults))
if err != nil {
return zero, err
}
encoded, err := json.Marshal(value)
if err != nil {
return zero, err
}
// Clone configuration without serializing borrowed json:"-" capabilities.
var result T
original, err := cloneConfiguration(reflect.ValueOf(&defaults).Elem(), 0)
if err != nil {
return zero, err
}
reflect.ValueOf(&result).Elem().Set(original)
dec := json.NewDecoder(bytes.NewReader(encoded))
dec.DisallowUnknownFields()
if err = dec.Decode(&result); err != nil {
return zero, err
}
return result, nil
}
func durations(v any, t reflect.Type) (any, error) {
if t == nil {
return v, nil

175
config/loader.go Normal file
View File

@@ -0,0 +1,175 @@
package config
import (
"errors"
"io"
"os"
"path/filepath"
"reflect"
"strings"
)
const maxConfigBytes = 1 << 20
const maxConfigDepth = 64
const maxConfigNodes = 100000
// Decoder must not retain or mutate input and must support concurrent calls
// when its Loader is shared. It is trusted application code, not a sandbox.
type Decoder interface {
Decode([]byte) (Document, error)
}
type DecoderFunc func([]byte) (Document, error)
func (f DecoderFunc) Decode(data []byte) (Document, error) { return f(data) }
// Loader owns an immutable per-instance decoder registry. No global registration.
type Loader struct {
decoders map[string]Decoder
sealed bool
}
type LoaderOption func(*Loader) error
// WithDecoder adds a new extension; builtin/duplicate extensions cannot be replaced.
func WithDecoder(extension string, decoder Decoder) LoaderOption {
return func(l *Loader) error {
if l == nil || l.decoders == nil || l.sealed {
return errors.New("config: decoder registry cannot be changed after construction")
}
ext := strings.ToLower(strings.TrimPrefix(extension, "."))
if ext == "" || strings.ContainsAny(ext, "./\\ \t\r\n") {
return errors.New("config: invalid decoder extension")
}
if decoder == nil {
return errors.New("config: nil decoder")
}
v := reflect.ValueOf(decoder)
if (v.Kind() == reflect.Pointer || v.Kind() == reflect.Func || v.Kind() == reflect.Map || v.Kind() == reflect.Slice || v.Kind() == reflect.Interface) && v.IsNil() {
return errors.New("config: nil decoder")
}
if _, exists := l.decoders[ext]; exists {
return errors.New("config: decoder extension already registered")
}
l.decoders[ext] = decoder
return nil
}
}
func NewLoader(options ...LoaderOption) (*Loader, error) {
l := &Loader{decoders: map[string]Decoder{"json": JSONDecoder{}, "yaml": YAMLDecoder{}, "yml": YAMLDecoder{}, "xml": XMLDecoder{}}}
for _, o := range options {
if o == nil {
return nil, errors.New("config: nil loader option")
}
if err := o(l); err != nil {
return nil, err
}
}
l.sealed = true
return l, nil
}
var defaultLoader, _ = NewLoader()
func selectLoader(loaders []*Loader) (*Loader, error) {
if len(loaders) == 0 {
return defaultLoader, nil
}
if len(loaders) != 1 || loaders[0] == nil {
return nil, errors.New("config: expected one non-nil loader")
}
return loaders[0], nil
}
func (l *Loader) read(path string) (doc Document, err error) {
defer func() {
if err != nil {
var d *Error
if errors.As(err, &d) {
c := *d
c.File = path
err = &c
} else {
reason := "cannot read configuration file"
if errors.Is(err, os.ErrNotExist) {
reason = "configuration file does not exist"
} else if errors.Is(err, os.ErrPermission) {
reason = "permission denied reading configuration file"
}
err = &Error{File: path, Reason: reason, cause: err}
}
}
}()
decoder := l.decoders[strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), ".")]
if decoder == nil {
return doc, &Error{Reason: "unsupported configuration format"}
}
info, e := os.Stat(path)
if e != nil {
return doc, e
}
if !info.Mode().IsRegular() {
return doc, &Error{Reason: "configuration must be a regular file"}
}
f, e := os.Open(path)
if e != nil {
return doc, e
}
defer f.Close()
info, e = f.Stat()
if e != nil {
return doc, e
}
if !info.Mode().IsRegular() {
return doc, &Error{Reason: "configuration must be a regular file"}
}
data, e := io.ReadAll(io.LimitReader(f, maxConfigBytes+1))
if e != nil {
return doc, e
}
if len(data) > maxConfigBytes {
return doc, &Error{Reason: "file exceeds 1 MiB"}
}
doc, err = decodeSafely(decoder, data)
if err != nil {
return doc, err
}
if _, ok := doc.Value.(map[string]any); !ok {
return doc, doc.diagnostic("", "root must be an object", nil)
}
return doc, nil
}
func decodeSafely(decoder Decoder, data []byte) (doc Document, err error) {
defer func() {
if recover() != nil {
err = &Error{Reason: "decoder panicked"}
}
}()
doc, err = decoder.Decode(data)
if err != nil {
var d *Error
if !errors.As(err, &d) {
err = &Error{Reason: "invalid configuration syntax", cause: err}
}
}
return
}
// DecodeFile overlays a bounded document onto independent defaults. With no
// loader argument it accepts JSON/YAML/XML. It never opens dependencies.
func DecodeFile[T any](path string, defaults T, loaders ...*Loader) (T, error) {
var zero T
l, err := selectLoader(loaders)
if err != nil {
return zero, err
}
doc, err := l.read(path)
if err != nil {
return zero, err
}
result, err := bindDocument(doc, defaults)
if err != nil {
d := validationError(err)
d.File = path
return zero, d
}
return result, nil
}

397
config/loader_test.go Normal file
View File

@@ -0,0 +1,397 @@
package config
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
"time"
)
type loaderExample struct {
Server Config `json:"server"`
Upload struct {
MaxBytes int64 `json:"maxBytes" envDefault:"1024"`
} `json:"upload"`
Values []int64 `json:"values"`
Optional *string `json:"optional"`
Enabled bool `json:"enabled"`
Dynamic any `json:"dynamic"`
}
func (c loaderExample) Validate() error {
if err := c.Server.Validate(); err != nil {
return AtField("server", err)
}
if c.Upload.MaxBytes < 0 {
return Invalid("upload.maxBytes", "must be nonnegative")
}
return nil
}
func noEnvironment(string) (string, bool) { return "", false }
func configFile(t *testing.T, ext, raw string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "service."+ext)
if err := os.WriteFile(path, []byte(raw), 0600); err != nil {
t.Fatal(err)
}
return path
}
func TestUnifiedFormatAndPrecedence(t *testing.T) {
formats := map[string]string{
"json": `{"server":{"http":{"readTimeout":"2s"}},"upload":{"maxBytes":7},"values":[9007199254740993],"optional":null,"enabled":true,"dynamic":{"id":9007199254740993}}`,
"yaml": "server:\n http:\n readTimeout: 2s\nupload:\n maxBytes: 7\nvalues: [9007199254740993]\noptional: null\nenabled: true\ndynamic:\n id: 9007199254740993\n",
"xml": `<config><server><http><readTimeout>2s</readTimeout></http></server><upload><maxBytes>7</maxBytes></upload><values type="array"><item>9007199254740993</item></values><optional type="null"/><enabled>true</enabled><dynamic><id type="number">9007199254740993</id></dynamic></config>`,
}
var reference loaderExample
for _, ext := range []string{"json", "yaml", "xml"} {
t.Run(ext, func(t *testing.T) {
old := "old"
base := loaderExample{Server: Default(), Optional: &old}
cfg, err := LoadProject(configFile(t, ext, formats[ext]), base, EnvOptions{Prefix: "APP_", Lookup: func(key string) (string, bool) {
v, ok := map[string]string{"APP_UPLOAD_MAX_BYTES": "0", "APP_ENABLED": "false"}[key]
return v, ok
}})
if err != nil {
t.Fatal(err)
}
if cfg.Upload.MaxBytes != 0 || cfg.Enabled || cfg.Optional != nil || cfg.Values[0] != 9007199254740993 || cfg.Server.HTTP.ReadTimeout != 2*time.Second {
t.Fatal("values changed", cfg)
}
if cfg.Dynamic.(map[string]any)["id"] != json.Number("9007199254740993") {
t.Fatal("dynamic integer precision lost")
}
if ext == "json" {
reference = cfg
} else if !reflect.DeepEqual(reference, cfg) {
t.Fatal("format-dependent semantics")
}
if base.Optional == nil || *base.Optional != "old" {
t.Fatal("defaults mutated")
}
})
}
cfg, err := LoadProject("", loaderExample{Server: Default()}, EnvOptions{Lookup: noEnvironment})
if err != nil || cfg.Upload.MaxBytes != 1024 {
t.Fatal(cfg, err)
}
}
func TestConfigDiagnosticsAndCauses(t *testing.T) {
for _, tc := range []struct {
ext, body, field string
line bool
}{
{"json", "{\n\"upload\": {\"maxBytes\":\"private-value\"}}", "upload.maxBytes", true},
{"yaml", "server:\n http:\n readTimeout: private-value", "server.http.readTimeout", true},
{"xml", "<config>\n<upload><maxBytes>private-value</maxBytes></upload></config>", "upload.maxBytes", true},
{"json", `{"upload":{"maxBytes":-1}}`, "upload.maxBytes", true},
{"yaml", "server:\n http:\n readTimeout: -1s", "server.http.readTimeout", true},
{"json", `{"upload":{"typo":3}}`, "upload.typo", true},
} {
t.Run(tc.ext+tc.field, func(t *testing.T) {
path := configFile(t, tc.ext, tc.body)
_, err := LoadProject(path, loaderExample{Server: Default()}, EnvOptions{Lookup: noEnvironment})
var d *Error
if !errors.As(err, &d) || d.File != path || d.Field != tc.field || d.Reason == "" || tc.line && d.Line < 1 {
t.Fatalf("missing location: %#v %v", d, err)
}
if strings.Contains(err.Error(), "private-value") {
t.Fatal("secret leaked", err)
}
})
}
path := filepath.Join(t.TempDir(), "missing.json")
_, err := LoadProject(path, loaderExample{}, EnvOptions{})
if !errors.Is(err, os.ErrNotExist) {
t.Fatal("I/O cause discarded", err)
}
_, err = DecodeFile(configFile(t, "json", `{"count":"private-value"}`), struct {
Count int `json:"count"`
}{})
var typed *json.UnmarshalTypeError
if !errors.As(err, &typed) {
t.Fatal("type cause discarded", err)
}
_, err = LoadProject(configFile(t, "yaml", "upload:\n maxBytes: 7"), loaderExample{Server: Default()}, EnvOptions{Prefix: "APP_", Lookup: func(k string) (string, bool) { return "-1", k == "APP_UPLOAD_MAX_BYTES" }})
var d *Error
if !errors.As(err, &d) || d.Env != "APP_UPLOAD_MAX_BYTES" || d.Field != "upload.maxBytes" || d.File != "" || d.Line != 0 {
t.Fatal("environment validation wrongly blamed file", err)
}
}
func TestMalformedFormatsAndLimits(t *testing.T) {
for ext, bodies := range map[string][]string{
"json": {`{"enabled":true,"enabled":false}`, `{} {}`, `{"upload":`, `{"enabled":true,"Enabled":false}`, strings.Repeat(`{"a":`, 70) + `0` + strings.Repeat(`}`, 70)},
"yaml": {"enabled: true\nenabled: false", "---\n{}\n---\n{}", "a: &x [1]\nb: *x", "a:\n <<: {}", "upload: [\n"},
"xml": {`<!DOCTYPE config><config/>`, `<config>&private-value;</config>`, `<config/><config/>`, `<config xmlns="x"/>`, `<config type="" type=""/>`, `<config><enabled>true</enabled><enabled>false</enabled></config>`, `<config>text<enabled>true</enabled></config>`, `<config><values type="array"><bad>1</bad></values></config>`},
} {
for i, raw := range bodies {
t.Run(fmt.Sprintf("%s%d", ext, i), func(t *testing.T) {
_, err := LoadProject(configFile(t, ext, raw), loaderExample{Server: Default()}, EnvOptions{Lookup: noEnvironment})
if err == nil || strings.Contains(err.Error(), "private-value") {
t.Fatal(err)
}
})
}
}
if _, err := DecodeFile(configFile(t, "json", strings.Repeat(" ", maxConfigBytes+1)), loaderExample{}); err == nil {
t.Fatal("unbounded file")
}
if _, err := DecodeFile(configFile(t, "json", string([]byte{'{', '"', 0xff, '"', ':', '0', '}'})), loaderExample{}); err == nil {
t.Fatal("invalid UTF-8")
}
}
func TestDecoderRegistryAndConcurrentUse(t *testing.T) {
decoder := DecoderFunc(func([]byte) (Document, error) {
return Document{Value: map[string]any{"upload": map[string]any{"maxBytes": json.Number("42")}}}, nil
})
l, err := NewLoader(WithDecoder("custom", decoder))
if err != nil {
t.Fatal(err)
}
path := configFile(t, "custom", "ignored")
var wg sync.WaitGroup
for i := 0; i < 24; i++ {
wg.Add(1)
go func() {
defer wg.Done()
cfg, e := LoadProject(path, loaderExample{Server: Default()}, EnvOptions{Lookup: noEnvironment}, l)
if e != nil || cfg.Upload.MaxBytes != 42 {
t.Error(e)
}
}()
}
wg.Wait()
if _, err := DecodeFile(path, loaderExample{}); err == nil {
t.Fatal("decoder registration leaked globally")
}
for _, options := range [][]LoaderOption{{WithDecoder("json", decoder)}, {WithDecoder("x", decoder), WithDecoder("x", decoder)}, {WithDecoder("x", DecoderFunc(nil))}} {
if _, err = NewLoader(options...); err == nil {
t.Fatal("invalid registration")
}
}
l, _ = NewLoader(WithDecoder("panic", DecoderFunc(func([]byte) (Document, error) { panic("private-value") })))
_, err = DecodeFile(configFile(t, "panic", ""), loaderExample{}, l)
if err == nil || strings.Contains(err.Error(), "private-value") {
t.Fatal(err)
}
cycle := map[string]any{}
cycle["self"] = cycle
l, _ = NewLoader(WithDecoder("cycle", DecoderFunc(func([]byte) (Document, error) { return Document{Value: map[string]any{"dynamic": cycle}}, nil })))
if _, err = DecodeFile(configFile(t, "cycle", ""), loaderExample{}, l); err == nil {
t.Fatal("decoder bypassed traversal limit")
}
}
func TestUnifiedLoggerNormalization(t *testing.T) {
type project struct {
Server Config `json:"server"`
}
base := project{Server: Default()}
base.Server.Log.File = "old.log"
base.Server.Log.Rotation.Enabled = true
for _, full := range []bool{false, true} {
name := "GO_OMNI_LOG_OUTPUT"
if full {
name = "GO_OMNI_SERVER_LOG_OUTPUT"
}
cfg, err := LoadProject("", base, EnvOptions{Prefix: "GO_OMNI_", Empty: "ignore", Aliases: ServerEnvAliases("GO_OMNI_", "server"), Lookup: func(k string) (string, bool) { return "stdout", k == name }})
if err != nil || cfg.Server.Log.File != "" || cfg.Server.Log.Rotation.Enabled || base.Server.Log.File != "old.log" {
t.Fatal(cfg, err)
}
}
}
func FuzzConfigurationDecoders(f *testing.F) {
f.Add(uint8(0), []byte(`{"count":1}`))
f.Add(uint8(1), []byte("count: 1"))
f.Add(uint8(2), []byte("<config><count>1</count></config>"))
f.Fuzz(func(t *testing.T, format uint8, data []byte) {
if len(data) > 8192 {
data = data[:8192]
}
ds := []Decoder{JSONDecoder{}, YAMLDecoder{}, XMLDecoder{}}
doc, err := ds[int(format)%len(ds)].Decode(data)
if err == nil {
_, _ = bindDocument(doc, struct {
Count int `json:"count"`
}{})
}
})
}
func BenchmarkConfiguration(b *testing.B) {
for name, tc := range map[string]struct {
d Decoder
raw string
}{"json": {JSONDecoder{}, `{"count":9007199254740993,"timeout":"5s","values":[1,2,3]}`}, "yaml": {YAMLDecoder{}, "count: 9007199254740993\ntimeout: 5s\nvalues: [1,2,3]"}, "xml": {XMLDecoder{}, `<config><count>9007199254740993</count><timeout>5s</timeout><values type="array"><item>1</item><item>2</item><item>3</item></values></config>`}} {
b.Run(name, func(b *testing.B) {
b.ReportAllocs()
raw := []byte(tc.raw)
for i := 0; i < b.N; i++ {
doc, err := tc.d.Decode(raw)
if err != nil {
b.Fatal(err)
}
_, err = bindDocument(doc, struct {
Count int64 `json:"count"`
Timeout time.Duration `json:"timeout"`
Values []int `json:"values"`
}{})
if err != nil {
b.Fatal(err)
}
}
})
}
}
// Text-only scalars and embedded config fields use the same behavior in each format.
type testText struct{ Value string }
func (v *testText) UnmarshalText(data []byte) error { v.Value = string(data); return nil }
func TestConfigScalarAndEmbedding(t *testing.T) {
type Fields struct {
Name string `json:"name"`
}
type C struct {
Fields
Nested Fields `json:"nested"`
Scalar testText `json:"scalar"`
Bytes []byte `json:"bytes"`
}
cases := map[string]string{
"json": `{"name":"outer","nested":{"name":"inner"},"scalar":"text","bytes":"aGk="}`,
"yaml": "name: outer\nnested:\n name: inner\nscalar: text\nbytes: aGk=\n",
"xml": `<config><name>outer</name><nested><name>inner</name></nested><scalar>text</scalar><bytes>aGk=</bytes></config>`,
}
for ext, data := range cases {
t.Run(ext, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "config."+ext)
os.WriteFile(path, []byte(data), 0600)
got, err := DecodeFile(path, &C{})
if err != nil {
t.Fatal(err)
}
if got.Name != "outer" || got.Nested.Name != "inner" || got.Scalar.Value != "text" || string(got.Bytes) != "hi" {
t.Fatal(got)
}
})
}
type Tagged struct {
Fields `json:"nested"`
}
doc, _ := (JSONDecoder{}).Decode([]byte(`{"name":"wrong"}`))
if _, err := bindDocument(doc, Tagged{}); err == nil {
t.Fatal("tagged embed incorrectly flattened")
}
}
func TestConfigDefaultsIsolationAndEmptyValues(t *testing.T) {
type Child struct {
Name string `json:"name"`
}
type C struct {
Child *Child `json:"child"`
Items []int `json:"items"`
Labels map[string]string `json:"labels"`
Enabled bool `json:"enabled"`
}
base := C{Child: &Child{Name: "old"}, Items: []int{1}, Labels: map[string]string{"old": "yes"}, Enabled: true}
doc, _ := (JSONDecoder{}).Decode([]byte(`{"child":{"name":"new"},"labels":{"new":"yes"},"items":[],"enabled":false}`))
got, err := bindDocument(doc, base)
if err != nil {
t.Fatal(err)
}
if got.Child.Name != "new" || got.Items == nil || len(got.Items) != 0 || got.Enabled {
t.Fatal(got)
}
if base.Child.Name != "old" || len(base.Items) != 1 || len(base.Labels) != 1 {
t.Fatal("mutated defaults")
}
bad, _ := (JSONDecoder{}).Decode([]byte(`{"child":{"name":"changed"},"items":"bad"}`))
if _, err := bindDocument(bad, base); err == nil {
t.Fatal("invalid input accepted")
}
if base.Child.Name != "old" {
t.Fatal("failed load changed default pointer")
}
null, _ := (JSONDecoder{}).Decode([]byte(`{"child":null,"items":null,"labels":null,"enabled":null}`))
got, err = bindDocument(null, base)
if err != nil || got.Child != nil || got.Items != nil || got.Labels != nil || !got.Enabled {
t.Fatal(got, err)
}
}
func TestDefaultStageDoesNotReadEnvironment(t *testing.T) {
reads := 0
type C struct {
Value int `json:"value" envDefault:"3"`
}
got, err := LoadProject("", C{}, EnvOptions{Lookup: func(string) (string, bool) { reads++; return "", false }})
if err != nil || got.Value != 3 || reads != 1 {
t.Fatal(got, err, reads)
}
d := locateError(Invalid("servers[0].timeout", "must be positive"), "a.json", Document{}, map[string]string{"servers": "APP_SERVERS"})
if d.Env != "APP_SERVERS" || d.File != "" {
t.Fatal(d)
}
}
func TestDecoderRegistryFrozen(t *testing.T) {
l, err := NewLoader()
if err != nil {
t.Fatal(err)
}
if err = WithDecoder("extra", JSONDecoder{})(l); err == nil {
t.Fatal("mutable registry")
}
}
func BenchmarkConfigurationStartup(b *testing.B) {
path := filepath.Join(b.TempDir(), "app.json")
if err := os.WriteFile(path, []byte(`{"server":{"http":{"readTimeout":"2s"}},"upload":{"maxBytes":4096}}`), 0600); err != nil {
b.Fatal(err)
}
options := EnvOptions{Prefix: "BENCH_", Lookup: func(name string) (string, bool) {
if name == "BENCH_UPLOAD_MAX_BYTES" {
return "8192", true
}
return "", false
}}
defaults := loaderExample{Server: Default()}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := LoadProject(path, defaults, options); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkConfigurationLarge(b *testing.B) {
type Entry struct {
Timeout time.Duration `json:"timeout"`
Count int `json:"count"`
}
type Large struct {
Entries []Entry `json:"entries"`
}
raw := []byte(`{"entries":[` + strings.TrimSuffix(strings.Repeat(`{"timeout":"3s","count":10},`, 1000), ",") + `]}`)
b.SetBytes(int64(len(raw)))
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
doc, err := (JSONDecoder{}).Decode(raw)
if err != nil {
b.Fatal(err)
}
if _, err = bindDocument(doc, Large{}); err != nil {
b.Fatal(err)
}
}
}

View File

@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/logx"
"math"
"os"
"reflect"
@@ -34,30 +35,114 @@ func ApplyDefaults[T any](base T) (T, error) { return projectOverlay(base, EnvOp
// LoadProject applies code/tag defaults, a strict optional file, environment,
// then the project's Validate method, before any logger or dependency is opened.
// envDefault is only applied to zero-valued defaults before reading the file.
func LoadProject[T any](path string, defaults T, options EnvOptions) (T, error) {
func LoadProject[T any](path string, defaults T, options EnvOptions, loaders ...*Loader) (T, error) {
var zero T
loader, err := selectLoader(loaders)
if err != nil {
return zero, err
}
cfg, err := projectOverlay(defaults, options, true)
if err != nil {
return zero, err
}
var doc Document
if path != "" {
cfg, err = DecodeFile(path, cfg)
doc, err = loader.read(path)
if err != nil {
return zero, errors.New("config: cannot decode file; check its path, format, field names and value types")
return zero, err
}
cfg, err = bindDocument(doc, cfg)
if err != nil {
return zero, locateError(err, path, doc, nil)
}
} else {
value, e := cloneConfiguration(reflect.ValueOf(&cfg).Elem(), 0)
if e != nil {
return zero, &Error{Reason: "cannot clone defaults", cause: e}
}
cfg = value.Interface().(T)
}
cfg, err = OverlayEnv(cfg, options)
origins := map[string]string{}
cfg, err = projectOverlay(cfg, options, false, origins)
if err != nil {
return zero, err
}
normalizeProject(reflect.ValueOf(&cfg).Elem(), "", origins, 0)
if valid, ok := any(&cfg).(interface{ Validate() error }); ok {
if err = valid.Validate(); err != nil {
return zero, err
return zero, locateError(err, path, doc, origins)
}
}
return cfg, nil
}
func locateError(err error, path string, doc Document, origins map[string]string) *Error {
d := validationError(err)
field := d.Field
for field != "" && origins[field] == "" {
index := strings.LastIndexAny(field, ".[")
if index < 0 {
field = ""
} else {
field = field[:index]
}
}
if env := origins[field]; env != "" {
d.Env = env
d.File = ""
d.Line = 0
d.Column = 0
return d
}
d.File = path
if d.Line == 0 {
p := doc.Positions[d.Field]
d.Line = p.Line
d.Column = p.Column
}
return d
}
// Framework-owned normalization stays inside the common pipeline. Explicit
// environment stream selection clears a file profile unless a file override
// was also provided; project code never needs to repeat this rule.
func normalizeProject(v reflect.Value, path string, origins map[string]string, depth int) {
if depth > 32 {
return
}
if v.Kind() == reflect.Pointer {
if v.IsNil() {
return
}
v = v.Elem()
}
if v.Kind() != reflect.Struct {
return
}
if v.Type() == reflect.TypeOf(Config{}) && v.CanAddr() {
c := v.Addr().Interface().(*Config)
if origins[joinField(path, "log.output")] != "" && c.Log.Output != "" && (origins[joinField(path, "log.file")] == "" || c.Log.File == "") {
c.Log.File = ""
c.Log.Rotation = logx.RotationConfig{}
}
return
}
for i := 0; i < v.NumField(); i++ {
f := v.Type().Field(i)
if f.PkgPath != "" {
continue
}
name := strings.Split(f.Tag.Get("json"), ",")[0]
if name == "-" {
continue
}
if name == "" {
name = f.Name
}
normalizeProject(v.Field(i), joinField(path, name), origins, depth+1)
}
}
// OverlayEnv overlays the entire project struct without mutating the input.
// Tags: env:"CUSTOM_NAME,required,secret,empty=reject"; env:"-" excludes a
// subtree. required means nonempty string/non-nil pointer or collection; false
@@ -70,7 +155,7 @@ func OverlayEnv[T any](base T, options EnvOptions) (T, error) {
var textType = reflect.TypeOf((*encoding.TextUnmarshaler)(nil)).Elem()
var durationType = reflect.TypeOf(time.Duration(0))
func projectOverlay[T any](base T, options EnvOptions, defaults bool) (T, error) {
func projectOverlay[T any](base T, options EnvOptions, defaults bool, sourceMaps ...map[string]string) (T, error) {
var zero T
if options.Lookup == nil {
options.Lookup = os.LookupEnv
@@ -88,8 +173,8 @@ func projectOverlay[T any](base T, options EnvOptions, defaults bool) (T, error)
result := reflect.New(v.Type()).Elem()
result.Set(v)
seen := map[string]bool{}
var walk func(reflect.Value, string, int) error
walk = func(value reflect.Value, prefix string, depth int) error {
var walk func(reflect.Value, string, string, int) error
walk = func(value reflect.Value, prefix, path string, depth int) error {
if depth > 32 {
return errors.New("config: nesting exceeds 32")
}
@@ -110,6 +195,7 @@ func projectOverlay[T any](base T, options EnvOptions, defaults bool) (T, error)
if jsonName == "" {
jsonName = field.Name
}
fieldPath := joinField(path, jsonName)
key := prefix + envName(jsonName)
if tag[0] != "" {
key = tag[0]
@@ -131,7 +217,7 @@ func projectOverlay[T any](base T, options EnvOptions, defaults bool) (T, error)
}
}
if dst.Kind() == reflect.Struct && !dst.Addr().Type().Implements(textType) {
if err := walk(dst, key+"_", depth+1); err != nil {
if err := walk(dst, key+"_", fieldPath, depth+1); err != nil {
return err
}
continue
@@ -142,14 +228,14 @@ func projectOverlay[T any](base T, options EnvOptions, defaults bool) (T, error)
if !dst.IsNil() {
child.Elem().Set(dst.Elem())
}
if err := walk(child.Elem(), key+"_", depth+1); err != nil {
if err := walk(child.Elem(), key+"_", fieldPath, depth+1); err != nil {
return err
}
if !dst.IsNil() || !child.Elem().IsZero() {
dst.Set(child)
}
if required && !defaults && dst.IsNil() {
return fmt.Errorf("config: %s is required", key)
return &Error{Env: key, Field: fieldPath, Reason: "is required"}
}
continue
}
@@ -164,7 +250,11 @@ func projectOverlay[T any](base T, options EnvOptions, defaults bool) (T, error)
return fmt.Errorf("config: duplicate environment key %s", key)
}
seen[key] = true
raw, present := options.Lookup(key)
var raw string
var present bool
if !defaults {
raw, present = options.Lookup(key)
}
selectedKey := key
if !present && !defaults {
for _, alias := range options.Aliases[key] {
@@ -180,7 +270,7 @@ func projectOverlay[T any](base T, options EnvOptions, defaults bool) (T, error)
}
if present && raw == "" && !defaults {
if empty == "reject" {
return fmt.Errorf("config: %s must not be empty", selectedKey)
return &Error{Env: selectedKey, Field: fieldPath, Reason: "must not be empty"}
}
if empty == "ignore" {
present = false
@@ -189,17 +279,24 @@ func projectOverlay[T any](base T, options EnvOptions, defaults bool) (T, error)
if present {
parsed := reflect.New(dst.Type()).Elem()
if err := parseEnvValue(parsed, raw); err != nil {
return fmt.Errorf("config: %s must be a valid %s", selectedKey, dst.Type())
origin := selectedKey
if defaults {
origin = ""
}
return &Error{Env: origin, Field: fieldPath, Reason: "expected a valid " + dst.Type().String(), cause: err}
}
dst.Set(parsed)
if !defaults && len(sourceMaps) > 0 {
sourceMaps[0][fieldPath] = selectedKey
}
}
if required && !defaults && emptyRequired(dst) {
return fmt.Errorf("config: %s is required", key)
return &Error{Env: key, Field: fieldPath, Reason: "is required"}
}
}
return nil
}
if err := walk(result, options.Prefix, 0); err != nil {
if err := walk(result, options.Prefix, "", 0); err != nil {
return zero, err
}
return result.Interface().(T), nil