feat: load explicit configuration references and remove implicit env overlays
This commit is contained in:
@@ -1,66 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestUnifiedEnvAliases(t *testing.T) {
|
||||
type project struct {
|
||||
Server Config `json:"server"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
base := project{Server: Default(), Label: "base"}
|
||||
for _, policy := range []string{"allow", "ignore", "reject"} {
|
||||
t.Run(policy, func(t *testing.T) {
|
||||
env := map[string]string{"TEST_HTTP_ADDR": "127.0.0.1:8099", "TEST_LOG_SERVICE": "", "TEST_SERVICE": "", "TEST_LABEL": ""}
|
||||
opts := EnvOptions{Prefix: "TEST_", Empty: policy, Aliases: ServerEnvAliases("TEST_", "server"), Lookup: func(k string) (string, bool) { v, ok := env[k]; return v, ok }}
|
||||
got, err := OverlayEnv(base, opts)
|
||||
if policy == "reject" {
|
||||
if err == nil || !strings.Contains(err.Error(), "empty") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Server.Address != "127.0.0.1:8099" {
|
||||
t.Fatal("alias ignored")
|
||||
}
|
||||
if policy == "allow" && got.Label != "" || policy == "ignore" && got.Label != "base" {
|
||||
t.Fatal("empty policy differs")
|
||||
}
|
||||
env["TEST_SERVER_ADDRESS"] = "127.0.0.1:9001"
|
||||
env["TEST_HTTP_READ_TIMEOUT"] = "3s"
|
||||
env["TEST_ENABLE_LOGGER"] = "false"
|
||||
env["TEST_TRUSTED_PROXIES"] = `["127.0.0.1"]`
|
||||
got, err = OverlayEnv(base, opts)
|
||||
if err != nil || got.Server.Address != "127.0.0.1:9001" || got.Server.HTTP.ReadTimeout != 3*time.Second || got.Server.EnableLogger || len(got.Server.TrustedProxies) != 1 {
|
||||
t.Fatal(got, err)
|
||||
}
|
||||
env["TEST_SERVER_ADDRESS"] = ""
|
||||
got, err = OverlayEnv(base, opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expected := ""
|
||||
if policy == "ignore" {
|
||||
expected = base.Server.Address
|
||||
}
|
||||
if got.Server.Address != expected {
|
||||
t.Fatal("empty canonical unexpectedly fell through to alias")
|
||||
}
|
||||
})
|
||||
}
|
||||
if base.Server.Address != ":8080" || base.Label != "base" {
|
||||
t.Fatal("defaults mutated")
|
||||
}
|
||||
}
|
||||
func TestAliasTypeErrorsDoNotExposeValue(t *testing.T) {
|
||||
opts := EnvOptions{Prefix: "X_", Empty: "reject", Aliases: ServerEnvAliases("X_", ""), Lookup: func(k string) (string, bool) { return "private-invalid", k == "X_HTTP_READ_TIMEOUT" }}
|
||||
if _, err := OverlayEnv(Default(), opts); err == nil || strings.Contains(err.Error(), "private-invalid") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,20 @@ func (b *documentBinder) bind(v any, dst reflect.Value, path string, depth int,
|
||||
if err := b.step(path, depth); err != nil {
|
||||
return err
|
||||
}
|
||||
if text, ok := v.(resolvedText); ok {
|
||||
if dst.Kind() != reflect.Pointer && dst.Type() != durationType && dst.CanAddr() && !dst.Addr().Type().Implements(jsonUnmarshalerType) && !dst.Addr().Type().Implements(textType) {
|
||||
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 := parseTextValue(dst, string(text)); err != nil {
|
||||
return b.doc.diagnostic(path, "expected a valid "+dst.Type().String(), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if dst.Kind() != reflect.Pointer {
|
||||
v = string(text)
|
||||
}
|
||||
}
|
||||
if v == nil {
|
||||
return b.decode(nil, dst, path)
|
||||
} // Preserve encoding/json null semantics.
|
||||
@@ -78,7 +92,7 @@ func (b *documentBinder) bind(v any, dst reflect.Value, path string, depth int,
|
||||
default:
|
||||
return b.decode(v, dst, path)
|
||||
}
|
||||
if err := parseEnvValue(dst, strings.TrimSpace(raw)); err != nil {
|
||||
if err := parseTextValue(dst, strings.TrimSpace(raw)); err != nil {
|
||||
return b.doc.diagnostic(path, "expected a duration such as 5s", err)
|
||||
}
|
||||
return nil
|
||||
@@ -102,7 +116,7 @@ func (b *documentBinder) bind(v any, dst reflect.Value, path string, depth int,
|
||||
}
|
||||
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 {
|
||||
if err := parseTextValue(dst, strings.TrimSpace(string(text))); err != nil {
|
||||
return b.doc.diagnostic(path, "expected a valid "+dst.Type().String(), err)
|
||||
}
|
||||
return nil
|
||||
@@ -143,7 +157,7 @@ func (b *documentBinder) bind(v any, dst reflect.Value, path string, depth int,
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
return b.doc.diagnostic(child, "unknown field", nil)
|
||||
return b.doc.diagnostic(child, unknownFieldReason(name, fields), nil)
|
||||
}
|
||||
if used[canonical] {
|
||||
return b.doc.diagnostic(child, "duplicate field binding", nil)
|
||||
@@ -237,6 +251,8 @@ func (b *documentBinder) dynamic(v any, path string, depth int) (any, error) {
|
||||
switch x := v.(type) {
|
||||
case Text:
|
||||
return string(x), nil
|
||||
case resolvedText:
|
||||
return string(x), nil
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(x))
|
||||
for _, k := range sortedKeys(x) {
|
||||
|
||||
@@ -1,132 +1,6 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoadFromEnvUsesDefaults(t *testing.T) {
|
||||
for _, key := range []string{
|
||||
"GO_OMNI_HTTP_ADDR", "GO_OMNI_REQUEST_ID_HEADER", "GO_OMNI_ENABLE_LOGGER",
|
||||
"GO_OMNI_TRUSTED_PROXIES",
|
||||
"GO_OMNI_TRANSPORT", "GO_OMNI_TLS_CERT_FILE", "GO_OMNI_TLS_KEY_FILE",
|
||||
"GO_OMNI_LOG_LEVEL", "GO_OMNI_LOG_FORMAT", "GO_OMNI_LOG_OUTPUT",
|
||||
"GO_OMNI_LOG_FILE", "GO_OMNI_SERVICE", "GO_OMNI_ENVIRONMENT",
|
||||
"GO_OMNI_LOG_ADD_SOURCE", "GO_OMNI_SHUTDOWN_TIMEOUT",
|
||||
"GO_OMNI_SLOW_REQUEST_THRESHOLD",
|
||||
} {
|
||||
t.Setenv(key, "")
|
||||
}
|
||||
|
||||
cfg, err := LoadFromEnv("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Address != ":8080" || cfg.Transport != TransportNetHTTP || cfg.TLS != (TLSConfig{}) || cfg.RequestIDHeader != "X-Request-ID" || cfg.ShutdownTimeout != 10*time.Second {
|
||||
t.Fatalf("unexpected defaults: %+v", cfg)
|
||||
}
|
||||
if cfg.EnableLogger || cfg.Log.Level != "info" || cfg.Log.Format != "json" || cfg.Log.Output != "stderr" {
|
||||
t.Fatalf("unexpected logger defaults: %+v", cfg.Log)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransportEnvironment(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name, transport, cert, key, errorField string
|
||||
}{
|
||||
{"default", "", "", "", ""},
|
||||
{"native", "native", "", "", ""},
|
||||
{"nativeTLS", "native", "server.crt", "server.key", ""},
|
||||
{"http2", "http2", "server.crt", "server.key", ""},
|
||||
{"unknown", "typo", "", "", "transport"},
|
||||
{"certOnly", "nethttp", "server.crt", "", "tls"},
|
||||
{"keyOnly", "native", "", "server.key", "tls"},
|
||||
{"http2WithoutTLS", "http2", "", "", "tls"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Setenv("TRANSPORT_TEST_TRANSPORT", test.transport)
|
||||
t.Setenv("TRANSPORT_TEST_TLS_CERT_FILE", test.cert)
|
||||
t.Setenv("TRANSPORT_TEST_TLS_KEY_FILE", test.key)
|
||||
cfg, err := LoadFromEnv("TRANSPORT_TEST_")
|
||||
if test.errorField != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), test.errorField) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := test.transport
|
||||
if want == "" {
|
||||
want = TransportNetHTTP
|
||||
}
|
||||
if cfg.Transport != want || cfg.TLS.CertFile != test.cert || cfg.TLS.KeyFile != test.key {
|
||||
t.Fatalf("settings: %+v", cfg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromEnvOverridesSettings(t *testing.T) {
|
||||
t.Setenv("GO_OMNI_HTTP_ADDR", "127.0.0.1:9090")
|
||||
t.Setenv("GO_OMNI_REQUEST_ID_HEADER", "X-Correlation-ID")
|
||||
t.Setenv("GO_OMNI_TRUSTED_PROXIES", "127.0.0.1, 10.0.0.0/8")
|
||||
t.Setenv("GO_OMNI_ENABLE_LOGGER", "true")
|
||||
t.Setenv("GO_OMNI_LOG_LEVEL", "debug")
|
||||
t.Setenv("GO_OMNI_LOG_FORMAT", "text")
|
||||
t.Setenv("GO_OMNI_LOG_OUTPUT", "stdout")
|
||||
t.Setenv("GO_OMNI_SERVICE", "billing")
|
||||
t.Setenv("GO_OMNI_ENVIRONMENT", "test")
|
||||
t.Setenv("GO_OMNI_LOG_ADD_SOURCE", "true")
|
||||
t.Setenv("GO_OMNI_SHUTDOWN_TIMEOUT", "3s")
|
||||
t.Setenv("GO_OMNI_SLOW_REQUEST_THRESHOLD", "500ms")
|
||||
|
||||
cfg, err := LoadFromEnv("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Address != "127.0.0.1:9090" || cfg.RequestIDHeader != "X-Correlation-ID" || !cfg.EnableLogger || len(cfg.TrustedProxies) != 2 || cfg.TrustedProxies[1] != "10.0.0.0/8" {
|
||||
t.Fatalf("unexpected application settings: %+v", cfg)
|
||||
}
|
||||
if cfg.ShutdownTimeout != 3*time.Second || cfg.Log.Level != "debug" || cfg.Log.Format != "text" || cfg.Log.Output != "stdout" || cfg.Log.Service != "billing" || cfg.Log.Environment != "test" || !cfg.Log.AddSource {
|
||||
t.Fatalf("unexpected logger settings: %+v", cfg.Log)
|
||||
}
|
||||
if cfg.SlowRequestThreshold != 500*time.Millisecond {
|
||||
t.Fatal(cfg.SlowRequestThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromEnvRejectsInvalidValues(t *testing.T) {
|
||||
t.Setenv("GO_OMNI_ENABLE_LOGGER", "sometimes")
|
||||
if _, err := LoadFromEnv(""); err == nil || !strings.Contains(err.Error(), "ENABLE_LOGGER") {
|
||||
t.Fatalf("expected invalid logger flag error, got %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("GO_OMNI_ENABLE_LOGGER", "")
|
||||
t.Setenv("GO_OMNI_SHUTDOWN_TIMEOUT", "later")
|
||||
if _, err := LoadFromEnv(""); err == nil || !strings.Contains(err.Error(), "SHUTDOWN_TIMEOUT") {
|
||||
t.Fatalf("expected invalid timeout error, got %v", err)
|
||||
}
|
||||
|
||||
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(), "trustedProxies") {
|
||||
t.Fatalf("expected invalid trusted proxy error, got %v", err)
|
||||
}
|
||||
t.Setenv("GO_OMNI_TRUSTED_PROXIES", "")
|
||||
cfg := Default()
|
||||
cfg.Address = ""
|
||||
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "address") {
|
||||
t.Fatalf("expected invalid address error, got %v", err)
|
||||
}
|
||||
cfg = Default()
|
||||
cfg.RequestIDHeader = " "
|
||||
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "requestIdHeader") {
|
||||
t.Fatalf("expected invalid request header error, got %v", err)
|
||||
}
|
||||
}
|
||||
import "testing"
|
||||
|
||||
func TestWithConfigAndWithLogConfig(t *testing.T) {
|
||||
settings := Default()
|
||||
@@ -147,23 +21,3 @@ func TestWithConfigAndWithLogConfig(t *testing.T) {
|
||||
t.Fatalf("log config was not replaced: %+v", cfg.Log)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyEnvPreservesProjectDefaults(t *testing.T) {
|
||||
cfg := Default()
|
||||
cfg.Address = "127.0.0.1:8123"
|
||||
cfg.Log.RedactKeys = []string{"bankAccount"}
|
||||
cfg.TrustedProxies = []string{"127.0.0.1"}
|
||||
t.Setenv("OVERLAY_TEST_LOG_LEVEL", "debug")
|
||||
got, err := ApplyEnv(cfg, "OVERLAY_TEST_")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Address != cfg.Address || got.Log.Level != "debug" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
got.Log.RedactKeys[0] = "changed"
|
||||
got.TrustedProxies[0] = "192.0.2.1"
|
||||
if cfg.Log.RedactKeys[0] != "bankAccount" || cfg.TrustedProxies[0] != "127.0.0.1" {
|
||||
t.Fatal("caller-owned defaults mutated")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,8 +82,9 @@ type Position struct{ Line, Column int }
|
||||
// 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
|
||||
Value any
|
||||
Positions map[string]Position
|
||||
environments map[string][]string
|
||||
}
|
||||
|
||||
type Text string
|
||||
|
||||
@@ -18,14 +18,15 @@ func TestEndpointConfiguration(t *testing.T) {
|
||||
if cfg.Endpoints.Liveness.Enabled || cfg.Endpoints.OpenAPI.Enabled || !cfg.Endpoints.Readiness.Enabled || cfg.Endpoints.Readiness.ResponseMode != "raw" {
|
||||
t.Fatal(cfg.Endpoints)
|
||||
}
|
||||
refs := configFile(t, "yaml", "endpoints:\n readiness:\n path: ${TEST_ENDPOINTS_READINESS_PATH}\n openapi:\n enabled: ${TEST_ENDPOINTS_OPENAPI_ENABLED}\n")
|
||||
t.Setenv("TEST_ENDPOINTS_READINESS_PATH", "/internal/ready")
|
||||
t.Setenv("TEST_ENDPOINTS_OPENAPI_ENABLED", "true")
|
||||
cfg, err = ApplyEnv(cfg, "TEST_")
|
||||
cfg, err = LoadProject(refs, cfg)
|
||||
if err != nil || cfg.Endpoints.Readiness.Path != "/internal/ready" || !cfg.Endpoints.OpenAPI.Enabled {
|
||||
t.Fatal(cfg.Endpoints, err)
|
||||
}
|
||||
t.Setenv("TEST_ENDPOINTS_OPENAPI_ENABLED", "invalid")
|
||||
if _, err = ApplyEnv(cfg, "TEST_"); err == nil {
|
||||
if _, err = LoadProject(refs, cfg); err == nil {
|
||||
t.Fatal("invalid bool accepted")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const defaultEnvPrefix = "GO_OMNI_"
|
||||
|
||||
// ServerEnvAliases returns the documented short names for Config fields.
|
||||
// field is the JSON path of Config in a project (usually "server"), or ""
|
||||
// for Config itself. Canonical names win over aliases. Both use the same
|
||||
// EnvOptions.Empty/Lookup and typed parser; collections use JSON syntax.
|
||||
func ServerEnvAliases(prefix, field string) map[string][]string {
|
||||
root := prefix
|
||||
if field != "" {
|
||||
for _, part := range strings.Split(field, ".") {
|
||||
root += envName(part) + "_"
|
||||
}
|
||||
}
|
||||
names := map[string]string{
|
||||
"ADDRESS": "HTTP_ADDR", "TRANSPORT": "TRANSPORT", "TLS_CERT_FILE": "TLS_CERT_FILE", "TLS_KEY_FILE": "TLS_KEY_FILE",
|
||||
"REQUEST_ID_HEADER": "REQUEST_ID_HEADER", "ENABLE_LOGGER": "ENABLE_LOGGER", "SLOW_REQUEST_THRESHOLD": "SLOW_REQUEST_THRESHOLD",
|
||||
"TRUSTED_PROXIES": "TRUSTED_PROXIES", "SHUTDOWN_TIMEOUT": "SHUTDOWN_TIMEOUT",
|
||||
"LOG_LEVEL": "LOG_LEVEL", "LOG_FORMAT": "LOG_FORMAT", "LOG_OUTPUT": "LOG_OUTPUT", "LOG_FILE": "LOG_FILE", "LOG_SERVICE": "SERVICE", "LOG_ENVIRONMENT": "ENVIRONMENT", "LOG_ADD_SOURCE": "LOG_ADD_SOURCE",
|
||||
"HTTP_READ_HEADER_TIMEOUT": "HTTP_READ_HEADER_TIMEOUT", "HTTP_READ_TIMEOUT": "HTTP_READ_TIMEOUT", "HTTP_WRITE_TIMEOUT": "HTTP_WRITE_TIMEOUT", "HTTP_IDLE_TIMEOUT": "HTTP_IDLE_TIMEOUT", "HTTP_MAX_HEADER_BYTES": "HTTP_MAX_HEADER_BYTES",
|
||||
"LIFECYCLE_STOP_TIMEOUT": "LIFECYCLE_STOP_TIMEOUT", "LIFECYCLE_DRAIN_TIMEOUT": "LIFECYCLE_DRAIN_TIMEOUT", "LIFECYCLE_CANCEL_TIMEOUT": "LIFECYCLE_CANCEL_TIMEOUT",
|
||||
}
|
||||
for _, endpoint := range []string{"LIVENESS", "READINESS", "OPENAPI"} {
|
||||
for _, part := range []string{"ENABLED", "PATH", "RESPONSE_MODE"} {
|
||||
if endpoint == "OPENAPI" && part == "RESPONSE_MODE" {
|
||||
continue
|
||||
}
|
||||
name := "ENDPOINTS_" + endpoint + "_" + part
|
||||
names[name] = name
|
||||
}
|
||||
}
|
||||
aliases := map[string][]string{}
|
||||
for key, alias := range names {
|
||||
if root+key != prefix+alias {
|
||||
aliases[root+key] = []string{prefix + alias}
|
||||
}
|
||||
}
|
||||
return aliases
|
||||
}
|
||||
|
||||
// LoadFromEnv loads defaults using the legacy short-name conventions.
|
||||
func LoadFromEnv(prefix string) (Config, error) { return ApplyEnv(Default(), prefix) }
|
||||
|
||||
// ApplyEnv retains legacy whitespace trimming, ignored empty variables and
|
||||
// comma-separated TRUSTED_PROXIES. New projects should use OverlayEnv with
|
||||
// ServerEnvAliases for one explicit empty-value policy across the whole config.
|
||||
func ApplyEnv(defaults Config, prefix string) (Config, error) {
|
||||
prefix = strings.TrimSpace(prefix)
|
||||
if prefix == "" {
|
||||
prefix = defaultEnvPrefix
|
||||
}
|
||||
lookup := func(key string) (string, bool) {
|
||||
raw, ok := os.LookupEnv(key)
|
||||
raw = strings.TrimSpace(raw)
|
||||
if ok && raw != "" && key == prefix+"TRUSTED_PROXIES" && !strings.HasPrefix(raw, "[") {
|
||||
values := strings.Split(raw, ",")
|
||||
for i := range values {
|
||||
values[i] = strings.TrimSpace(values[i])
|
||||
}
|
||||
data, _ := json.Marshal(values)
|
||||
raw = string(data)
|
||||
}
|
||||
return raw, ok
|
||||
}
|
||||
defaults.Errors = defaults.Errors.Clone()
|
||||
defaults.TrustedProxies = append([]string(nil), defaults.TrustedProxies...)
|
||||
defaults.Log.RedactKeys = append([]string(nil), defaults.Log.RedactKeys...)
|
||||
cfg, err := OverlayEnv(defaults, EnvOptions{Prefix: prefix, Empty: "ignore", Lookup: lookup, Aliases: ServerEnvAliases(prefix, "")})
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if err = cfg.Validate(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
@@ -21,7 +21,7 @@ func TestCatalogConfigFiles(t *testing.T) {
|
||||
if err := os.WriteFile(path, []byte(file.body), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := config.LoadFile(path, "CATALOG_FIXTURE_")
|
||||
cfg, err := config.LoadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -40,7 +40,7 @@ func TestCatalogConfigFiles(t *testing.T) {
|
||||
if err := os.WriteFile(path, []byte(body), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := config.LoadFile(path, "CATALOG_FIXTURE_"); err == nil {
|
||||
if _, err := config.LoadFile(path); err == nil {
|
||||
t.Fatal("accepted invalid catalog", body)
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ func TestCatalogReloadSnapshotIsolation(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer logger.Close()
|
||||
reloader, err := config.NewReloader(path, "CATALOG_RELOAD_", logger)
|
||||
reloader, err := config.NewReloader(path, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ func TestTemplateConfigFiles(t *testing.T) {
|
||||
if err := os.WriteFile(path, []byte(file.body), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := config.LoadFile(path, "TEMPLATE_TEST_")
|
||||
cfg, err := config.LoadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -4,103 +4,38 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
)
|
||||
|
||||
func durations(v any, t reflect.Type) (any, error) {
|
||||
if t == nil {
|
||||
return v, nil
|
||||
}
|
||||
for t.Kind() == reflect.Pointer {
|
||||
t = t.Elem()
|
||||
}
|
||||
if t == reflect.TypeOf(time.Duration(0)) {
|
||||
if s, ok := v.(string); ok {
|
||||
d, err := time.ParseDuration(s)
|
||||
return int64(d), err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
if t.Kind() == reflect.Struct {
|
||||
if m, ok := v.(map[string]any); ok {
|
||||
for n := 0; n < t.NumField(); n++ {
|
||||
field := t.Field(n)
|
||||
name := strings.Split(field.Tag.Get("json"), ",")[0]
|
||||
if name == "-" {
|
||||
continue
|
||||
}
|
||||
if name == "" {
|
||||
name = field.Name
|
||||
}
|
||||
if item, exists := m[name]; exists {
|
||||
converted, err := durations(item, field.Type)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m[name] = converted
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if t.Kind() == reflect.Map && t.Key().Kind() == reflect.String {
|
||||
if values, ok := v.(map[string]any); ok {
|
||||
for key, value := range values {
|
||||
converted, err := durations(value, t.Elem())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values[key] = converted
|
||||
}
|
||||
}
|
||||
}
|
||||
if t.Kind() == reflect.Slice {
|
||||
if items, ok := v.([]any); ok {
|
||||
for n, item := range items {
|
||||
converted, err := durations(item, t.Elem())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items[n] = converted
|
||||
}
|
||||
}
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
func LoadFile(path, prefix string) (Config, error) {
|
||||
cfg, err := DecodeFile(path, Default())
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return ApplyEnv(cfg, prefix)
|
||||
}
|
||||
// LoadFile loads and validates framework configuration using explicit file references.
|
||||
func LoadFile(path string) (Config, error) { return LoadProject(path, Default()) }
|
||||
|
||||
// Reloader only accepts log.level changes. Static changes and invalid files
|
||||
// leave the current snapshot and logger untouched. Watch does not own a goroutine.
|
||||
type Reloader struct {
|
||||
mu sync.Mutex
|
||||
path, prefix string
|
||||
current Config
|
||||
logger *logx.Logger
|
||||
mu sync.Mutex
|
||||
path string
|
||||
current Config
|
||||
logger *logx.Logger
|
||||
}
|
||||
|
||||
func NewReloader(path, prefix string, logger *logx.Logger) (*Reloader, error) {
|
||||
cfg, err := LoadFile(path, prefix)
|
||||
func NewReloader(path string, logger *logx.Logger) (*Reloader, error) {
|
||||
cfg, err := LoadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = logger.SetLevel(cfg.Log.Level); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Reloader{path: path, prefix: prefix, current: cfg, logger: logger}, nil
|
||||
return &Reloader{path: path, current: cfg, logger: logger}, nil
|
||||
}
|
||||
func (r *Reloader) Reload() error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
next, err := LoadFile(r.path, r.prefix)
|
||||
next, err := LoadFile(r.path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ func TestFileMergeAndSafeReload(t *testing.T) {
|
||||
}
|
||||
write("address: ':8123'\nshutdownTimeout: 7s\nlog:\n level: info\n")
|
||||
t.Setenv("TEST_MODULE_HTTP_ADDR", ":9123")
|
||||
cfg, err := LoadFile(path, "TEST_MODULE_")
|
||||
if err != nil || cfg.Address != ":9123" || cfg.ShutdownTimeout != 7*time.Second {
|
||||
cfg, err := LoadFile(path)
|
||||
if err != nil || cfg.Address != ":8123" || cfg.ShutdownTimeout != 7*time.Second {
|
||||
t.Fatal(cfg, err)
|
||||
}
|
||||
var output bytes.Buffer
|
||||
@@ -33,7 +33,7 @@ func TestFileMergeAndSafeReload(t *testing.T) {
|
||||
}
|
||||
defer l.Close()
|
||||
child := l.With("scope", "test")
|
||||
r, err := NewReloader(path, "TEST_MODULE_", l)
|
||||
r, err := NewReloader(path, l)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ func TestHTTPFileAndEnvironment(t *testing.T) {
|
||||
if err := os.WriteFile(path, []byte(file.body), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := config.LoadFile(path, "HTTP_TEST_")
|
||||
cfg, err := config.LoadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -35,7 +35,10 @@ func TestHTTPFileAndEnvironment(t *testing.T) {
|
||||
t.Setenv("HTTP_TEST_HTTP_WRITE_TIMEOUT", "8s")
|
||||
t.Setenv("HTTP_TEST_HTTP_IDLE_TIMEOUT", "9s")
|
||||
t.Setenv("HTTP_TEST_HTTP_MAX_HEADER_BYTES", "16384")
|
||||
got, err := config.ApplyEnv(cfg, "HTTP_TEST_")
|
||||
if err := os.WriteFile(path, []byte(`{"http":{"readHeaderTimeout":"${HTTP_TEST_HTTP_READ_HEADER_TIMEOUT}","readTimeout":"${HTTP_TEST_HTTP_READ_TIMEOUT}","writeTimeout":"${HTTP_TEST_HTTP_WRITE_TIMEOUT}","idleTimeout":"${HTTP_TEST_HTTP_IDLE_TIMEOUT}","maxHeaderBytes":"${HTTP_TEST_HTTP_MAX_HEADER_BYTES}"}}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := config.LoadProject(path, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -55,7 +58,12 @@ func TestHTTPInvalidAndUnsupportedSettings(t *testing.T) {
|
||||
value = "-1s"
|
||||
}
|
||||
t.Setenv("HTTP_INVALID_HTTP_"+key, value)
|
||||
if _, err := config.LoadFromEnv("HTTP_INVALID_"); err == nil {
|
||||
names := map[string]string{"READ_HEADER_TIMEOUT": "readHeaderTimeout", "READ_TIMEOUT": "readTimeout", "WRITE_TIMEOUT": "writeTimeout", "IDLE_TIMEOUT": "idleTimeout", "MAX_HEADER_BYTES": "maxHeaderBytes"}
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"http":{"`+names[key]+`":"${HTTP_INVALID_HTTP_`+key+`}"}}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := config.LoadFile(path); err == nil {
|
||||
t.Fatal("invalid HTTP value accepted")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -14,7 +14,7 @@ func TestLifecycleConfiguration(t *testing.T) {
|
||||
if err := os.WriteFile(path, []byte("lifecycle:\n stopTimeout: 2s\n drainTimeout: 3s\n cancelTimeout: 4s\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := config.LoadFile(path, "PHASE_TEST_")
|
||||
cfg, err := config.LoadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -24,13 +24,16 @@ func TestLifecycleConfiguration(t *testing.T) {
|
||||
for _, key := range []string{"STOP_TIMEOUT", "DRAIN_TIMEOUT", "CANCEL_TIMEOUT"} {
|
||||
t.Setenv("PHASE_TEST_LIFECYCLE_"+key, "6s")
|
||||
}
|
||||
cfg, err = config.ApplyEnv(cfg, "PHASE_TEST_")
|
||||
if err := os.WriteFile(path, []byte("lifecycle:\n stopTimeout: ${PHASE_TEST_LIFECYCLE_STOP_TIMEOUT}\n drainTimeout: ${PHASE_TEST_LIFECYCLE_DRAIN_TIMEOUT}\n cancelTimeout: ${PHASE_TEST_LIFECYCLE_CANCEL_TIMEOUT}\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err = config.LoadProject(path, cfg)
|
||||
if err != nil || cfg.Lifecycle.StopTimeout != 6*time.Second || cfg.Lifecycle.DrainTimeout != 6*time.Second || cfg.Lifecycle.CancelTimeout != 6*time.Second {
|
||||
t.Fatal(cfg.Lifecycle, err)
|
||||
}
|
||||
for _, v := range []string{"bad", "-1s"} {
|
||||
t.Setenv("PHASE_TEST_LIFECYCLE_DRAIN_TIMEOUT", v)
|
||||
if _, err := config.ApplyEnv(cfg, "PHASE_TEST_"); err == nil {
|
||||
if _, err := config.LoadProject(path, cfg); err == nil {
|
||||
t.Fatal("invalid duration accepted")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ func (f DecoderFunc) Decode(data []byte) (Document, error) { return f(data) }
|
||||
type Loader struct {
|
||||
decoders map[string]Decoder
|
||||
sealed bool
|
||||
lookup func(string) (string, bool)
|
||||
}
|
||||
type LoaderOption func(*Loader) error
|
||||
|
||||
@@ -55,7 +56,7 @@ func WithDecoder(extension string, decoder Decoder) LoaderOption {
|
||||
}
|
||||
|
||||
func NewLoader(options ...LoaderOption) (*Loader, error) {
|
||||
l := &Loader{decoders: map[string]Decoder{"json": JSONDecoder{}, "yaml": YAMLDecoder{}, "yml": YAMLDecoder{}, "xml": XMLDecoder{}}}
|
||||
l := &Loader{lookup: os.LookupEnv, 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")
|
||||
@@ -135,7 +136,7 @@ func (l *Loader) read(path string) (doc Document, err error) {
|
||||
if _, ok := doc.Value.(map[string]any); !ok {
|
||||
return doc, doc.diagnostic("", "root must be an object", nil)
|
||||
}
|
||||
return doc, nil
|
||||
return resolveDocument(doc, l.lookup)
|
||||
}
|
||||
func decodeSafely(decoder Decoder, data []byte) (doc Document, err error) {
|
||||
defer func() {
|
||||
@@ -165,11 +166,12 @@ func DecodeFile[T any](path string, defaults T, loaders ...*Loader) (T, error) {
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
if err := rejectEnvironmentTags(reflect.TypeOf(defaults), map[reflect.Type]bool{}); err != nil {
|
||||
return zero, err
|
||||
}
|
||||
result, err := bindDocument(doc, defaults)
|
||||
if err != nil {
|
||||
d := validationError(err)
|
||||
d.File = path
|
||||
return zero, d
|
||||
return zero, locateError(err, path, doc)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
type loaderExample struct {
|
||||
Server Config `json:"server"`
|
||||
Upload struct {
|
||||
MaxBytes int64 `json:"maxBytes" envDefault:"1024"`
|
||||
MaxBytes int64 `json:"maxBytes"`
|
||||
} `json:"upload"`
|
||||
Values []int64 `json:"values"`
|
||||
Optional *string `json:"optional"`
|
||||
@@ -45,19 +45,19 @@ func configFile(t *testing.T, ext, raw string) string {
|
||||
|
||||
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>`,
|
||||
"json": `{"server":{"http":{"readTimeout":"2s"}},"upload":{"maxBytes":"${APP_UPLOAD_MAX_BYTES}"},"values":[9007199254740993],"optional":null,"enabled":"${APP_ENABLED}","dynamic":{"id":9007199254740993}}`,
|
||||
"yaml": "server:\n http:\n readTimeout: 2s\nupload:\n maxBytes: \"${APP_UPLOAD_MAX_BYTES}\"\nvalues: [9007199254740993]\noptional: null\nenabled: \"${APP_ENABLED}\"\ndynamic:\n id: 9007199254740993\n",
|
||||
"xml": `<config><server><http><readTimeout>2s</readTimeout></http></server><upload><maxBytes>${APP_UPLOAD_MAX_BYTES}</maxBytes></upload><values type="array"><item>9007199254740993</item></values><optional type="null"/><enabled>${APP_ENABLED}</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) {
|
||||
cfg, err := LoadProject(configFile(t, ext, formats[ext]), base, testLoader(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)
|
||||
}
|
||||
@@ -77,8 +77,8 @@ func TestUnifiedFormatAndPrecedence(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
cfg, err := LoadProject("", loaderExample{Server: Default()}, EnvOptions{Lookup: noEnvironment})
|
||||
if err != nil || cfg.Upload.MaxBytes != 1024 {
|
||||
cfg, err := LoadProject("", loaderExample{Server: Default()}, testLoader(noEnvironment))
|
||||
if err != nil || cfg.Upload.MaxBytes != 0 {
|
||||
t.Fatal(cfg, err)
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,7 @@ func TestConfigDiagnosticsAndCauses(t *testing.T) {
|
||||
} {
|
||||
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})
|
||||
_, err := LoadProject(path, loaderExample{Server: Default()}, testLoader(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)
|
||||
@@ -108,7 +108,7 @@ func TestConfigDiagnosticsAndCauses(t *testing.T) {
|
||||
})
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "missing.json")
|
||||
_, err := LoadProject(path, loaderExample{}, EnvOptions{})
|
||||
_, err := LoadProject(path, loaderExample{})
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatal("I/O cause discarded", err)
|
||||
}
|
||||
@@ -119,11 +119,12 @@ func TestConfigDiagnosticsAndCauses(t *testing.T) {
|
||||
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" }})
|
||||
_, err = LoadProject(configFile(t, "yaml", "upload:\n maxBytes: \"${APP_UPLOAD_MAX_BYTES}\""), loaderExample{Server: Default()}, testLoader(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)
|
||||
if !errors.As(err, &d) || d.Env != "APP_UPLOAD_MAX_BYTES" || d.Field != "upload.maxBytes" || d.File == "" || d.Line != 2 {
|
||||
t.Fatal("missing file and variable provenance", err)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestMalformedFormatsAndLimits(t *testing.T) {
|
||||
@@ -134,7 +135,7 @@ func TestMalformedFormatsAndLimits(t *testing.T) {
|
||||
} {
|
||||
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})
|
||||
_, err := LoadProject(configFile(t, ext, raw), loaderExample{Server: Default()}, testLoader(noEnvironment))
|
||||
if err == nil || strings.Contains(err.Error(), "private-value") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -163,7 +164,7 @@ func TestDecoderRegistryAndConcurrentUse(t *testing.T) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
cfg, e := LoadProject(path, loaderExample{Server: Default()}, EnvOptions{Lookup: noEnvironment}, l)
|
||||
cfg, e := LoadProject(path, loaderExample{Server: Default()}, l)
|
||||
if e != nil || cfg.Upload.MaxBytes != 42 {
|
||||
t.Error(e)
|
||||
}
|
||||
@@ -191,25 +192,6 @@ func TestDecoderRegistryAndConcurrentUse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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"))
|
||||
@@ -332,18 +314,21 @@ func TestConfigDefaultsIsolationAndEmptyValues(t *testing.T) {
|
||||
func TestDefaultStageDoesNotReadEnvironment(t *testing.T) {
|
||||
reads := 0
|
||||
type C struct {
|
||||
Value int `json:"value" envDefault:"3"`
|
||||
Value int `json:"value"`
|
||||
}
|
||||
got, err := LoadProject("", C{}, EnvOptions{Lookup: func(string) (string, bool) { reads++; return "", false }})
|
||||
if err != nil || got.Value != 3 || reads != 1 {
|
||||
got, err := LoadProject("", C{Value: 3}, testLoader(func(string) (string, bool) { reads++; return "9", true }))
|
||||
if err != nil || got.Value != 3 || reads != 0 {
|
||||
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 testLoader(lookup func(string) (string, bool)) *Loader {
|
||||
l, err := NewLoader(WithLookupEnv(lookup))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return l
|
||||
}
|
||||
func TestDecoderRegistryFrozen(t *testing.T) {
|
||||
l, err := NewLoader()
|
||||
if err != nil {
|
||||
@@ -358,17 +343,12 @@ func BenchmarkConfigurationStartup(b *testing.B) {
|
||||
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 {
|
||||
if _, err := LoadProject(path, defaults); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,318 +1,105 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"math"
|
||||
"os"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// EnvOptions controls typed project configuration overlays. Explicit env tags
|
||||
// are absolute names; otherwise JSON names become PREFIX_NESTED_FIELD names.
|
||||
// Empty is allow (default), ignore or reject. No input values appear in errors.
|
||||
type EnvOptions struct {
|
||||
Prefix string
|
||||
Empty string
|
||||
Lookup func(string) (string, bool)
|
||||
// Aliases maps canonical absolute environment names to ordered fallback names.
|
||||
// A present canonical value wins, including an explicit empty value.
|
||||
Aliases map[string][]string
|
||||
}
|
||||
|
||||
// ApplyDefaults applies envDefault tags to zero fields before file overlays.
|
||||
func ApplyDefaults[T any](base T) (T, error) { return projectOverlay(base, EnvOptions{}, true) }
|
||||
|
||||
// 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, loaders ...*Loader) (T, error) {
|
||||
// LoadProject clones defaults, resolves only explicit file references, binds the
|
||||
// provided fields and validates. Empty path uses only defaults. No env tags,
|
||||
// prefixes, aliases or implicit environment overlays are applied.
|
||||
func LoadProject[T any](path string, defaults T, 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 {
|
||||
if err = rejectEnvironmentTags(reflect.TypeOf(defaults), map[reflect.Type]bool{}); err != nil {
|
||||
return zero, err
|
||||
}
|
||||
var doc Document
|
||||
doc := Document{Value: map[string]any{}}
|
||||
if path != "" {
|
||||
doc, err = loader.read(path)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
origins := map[string]string{}
|
||||
cfg, err = projectOverlay(cfg, options, false, origins)
|
||||
cfg, err := bindDocument(doc, defaults)
|
||||
if err != nil {
|
||||
return zero, err
|
||||
return zero, locateError(err, path, doc)
|
||||
}
|
||||
normalizeProject(reflect.ValueOf(&cfg).Elem(), "", origins, 0)
|
||||
if valid, ok := any(&cfg).(interface{ Validate() error }); ok {
|
||||
if err = valid.Validate(); err != nil {
|
||||
return zero, locateError(err, path, doc, origins)
|
||||
return zero, locateError(err, path, doc)
|
||||
}
|
||||
} else if valid, ok := any(cfg).(interface{ Validate() error }); ok {
|
||||
if err = valid.Validate(); err != nil {
|
||||
return zero, locateError(err, path, doc)
|
||||
}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func locateError(err error, path string, doc Document, origins map[string]string) *Error {
|
||||
func locateError(err error, path string, doc Document) *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
|
||||
}
|
||||
for field := d.Field; field != ""; {
|
||||
if names := doc.environments[field]; len(names) > 0 {
|
||||
d.Env = strings.Join(names, ", ")
|
||||
break
|
||||
}
|
||||
i := strings.LastIndexAny(field, ".[")
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
field = field[:i]
|
||||
}
|
||||
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
|
||||
// Reject removed declarations rather than silently ignoring deployment rules.
|
||||
func rejectEnvironmentTags(t reflect.Type, seen map[reflect.Type]bool) error {
|
||||
if t == nil || seen[t] {
|
||||
return nil
|
||||
}
|
||||
if v.Kind() == reflect.Pointer {
|
||||
if v.IsNil() {
|
||||
return
|
||||
seen[t] = true
|
||||
switch t.Kind() {
|
||||
case reflect.Pointer, reflect.Slice, reflect.Array, reflect.Map:
|
||||
return rejectEnvironmentTags(t.Elem(), seen)
|
||||
case reflect.Struct:
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
f := t.Field(i)
|
||||
if f.PkgPath != "" {
|
||||
continue
|
||||
}
|
||||
_, env := f.Tag.Lookup("env")
|
||||
_, def := f.Tag.Lookup("envDefault")
|
||||
if env || def {
|
||||
return &Error{Field: f.Name, Reason: "env/envDefault tags were removed; use Default() and explicit file references; see CONFIG.md"}
|
||||
}
|
||||
if f.Tag.Get("json") != "-" {
|
||||
if err := rejectEnvironmentTags(f.Type, seen); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
// and zero are valid scalars. Use project Validate for semantic constraints.
|
||||
// Slices/maps use JSON syntax, avoiding ambiguous comma and empty semantics.
|
||||
func OverlayEnv[T any](base T, options EnvOptions) (T, error) {
|
||||
return projectOverlay(base, options, false)
|
||||
return nil
|
||||
}
|
||||
|
||||
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, sourceMaps ...map[string]string) (T, error) {
|
||||
var zero T
|
||||
if options.Lookup == nil {
|
||||
options.Lookup = os.LookupEnv
|
||||
}
|
||||
if options.Empty == "" {
|
||||
options.Empty = "allow"
|
||||
}
|
||||
if options.Empty != "allow" && options.Empty != "ignore" && options.Empty != "reject" {
|
||||
return zero, errors.New("config: empty policy must be allow, ignore or reject")
|
||||
}
|
||||
v := reflect.ValueOf(&base).Elem()
|
||||
if v.Kind() != reflect.Struct {
|
||||
return zero, errors.New("config: project configuration must be a struct")
|
||||
}
|
||||
result := reflect.New(v.Type()).Elem()
|
||||
result.Set(v)
|
||||
seen := map[string]bool{}
|
||||
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")
|
||||
}
|
||||
for i := 0; i < value.NumField(); i++ {
|
||||
field := value.Type().Field(i)
|
||||
dst := value.Field(i)
|
||||
if !dst.CanSet() || field.PkgPath != "" {
|
||||
continue
|
||||
}
|
||||
tag := strings.Split(field.Tag.Get("env"), ",")
|
||||
if tag[0] == "-" {
|
||||
continue
|
||||
}
|
||||
jsonName := strings.Split(field.Tag.Get("json"), ",")[0]
|
||||
if jsonName == "-" && tag[0] == "" {
|
||||
continue
|
||||
}
|
||||
if jsonName == "" {
|
||||
jsonName = field.Name
|
||||
}
|
||||
fieldPath := joinField(path, jsonName)
|
||||
key := prefix + envName(jsonName)
|
||||
if tag[0] != "" {
|
||||
key = tag[0]
|
||||
}
|
||||
empty, required := options.Empty, false
|
||||
for _, opt := range tag[1:] {
|
||||
switch opt {
|
||||
case "required":
|
||||
required = true
|
||||
case "secret", "":
|
||||
case "empty=allow":
|
||||
empty = "allow"
|
||||
case "empty=ignore":
|
||||
empty = "ignore"
|
||||
case "empty=reject":
|
||||
empty = "reject"
|
||||
default:
|
||||
return fmt.Errorf("config: invalid env tag for %s", key)
|
||||
}
|
||||
}
|
||||
if dst.Kind() == reflect.Struct && !dst.Addr().Type().Implements(textType) {
|
||||
if err := walk(dst, key+"_", fieldPath, depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if dst.Kind() == reflect.Pointer && dst.Type().Elem().Kind() == reflect.Struct && !dst.Type().Implements(textType) {
|
||||
// Clone before traversing so a failed overlay never edits defaults.
|
||||
child := reflect.New(dst.Type().Elem())
|
||||
if !dst.IsNil() {
|
||||
child.Elem().Set(dst.Elem())
|
||||
}
|
||||
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 &Error{Env: key, Field: fieldPath, Reason: "is required"}
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Unexported runtime capabilities are skipped unless explicitly tagged.
|
||||
if dst.Kind() == reflect.Func || dst.Kind() == reflect.Chan || dst.Kind() == reflect.Interface {
|
||||
if tag[0] != "" {
|
||||
return fmt.Errorf("config: %s has unsupported environment type", key)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if seen[key] {
|
||||
return fmt.Errorf("config: duplicate environment key %s", key)
|
||||
}
|
||||
seen[key] = true
|
||||
var raw string
|
||||
var present bool
|
||||
if !defaults {
|
||||
raw, present = options.Lookup(key)
|
||||
}
|
||||
selectedKey := key
|
||||
if !present && !defaults {
|
||||
for _, alias := range options.Aliases[key] {
|
||||
if raw, present = options.Lookup(alias); present {
|
||||
selectedKey = alias
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if defaults {
|
||||
raw, present = field.Tag.Lookup("envDefault")
|
||||
present = present && dst.IsZero()
|
||||
}
|
||||
if present && raw == "" && !defaults {
|
||||
if empty == "reject" {
|
||||
return &Error{Env: selectedKey, Field: fieldPath, Reason: "must not be empty"}
|
||||
}
|
||||
if empty == "ignore" {
|
||||
present = false
|
||||
}
|
||||
}
|
||||
if present {
|
||||
parsed := reflect.New(dst.Type()).Elem()
|
||||
if err := parseEnvValue(parsed, raw); err != nil {
|
||||
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 &Error{Env: key, Field: fieldPath, Reason: "is required"}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := walk(result, options.Prefix, "", 0); err != nil {
|
||||
return zero, err
|
||||
}
|
||||
return result.Interface().(T), nil
|
||||
}
|
||||
|
||||
func emptyRequired(v reflect.Value) bool {
|
||||
switch v.Kind() {
|
||||
case reflect.String:
|
||||
return v.Len() == 0
|
||||
case reflect.Pointer, reflect.Map, reflect.Slice:
|
||||
return v.IsNil()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseEnvValue(v reflect.Value, raw string) (err error) {
|
||||
func parseTextValue(v reflect.Value, raw string) (err error) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
err = errors.New("invalid custom value")
|
||||
@@ -320,7 +107,7 @@ func parseEnvValue(v reflect.Value, raw string) (err error) {
|
||||
}()
|
||||
if v.Kind() == reflect.Pointer {
|
||||
v.Set(reflect.New(v.Type().Elem()))
|
||||
return parseEnvValue(v.Elem(), raw)
|
||||
return parseTextValue(v.Elem(), raw)
|
||||
}
|
||||
if v.CanAddr() && v.Addr().Type().Implements(textType) {
|
||||
return v.Addr().Interface().(encoding.TextUnmarshaler).UnmarshalText([]byte(raw))
|
||||
@@ -363,37 +150,6 @@ func parseEnvValue(v reflect.Value, raw string) (err error) {
|
||||
v.SetFloat(x)
|
||||
}
|
||||
return err
|
||||
case reflect.Slice, reflect.Map:
|
||||
var value any
|
||||
if err := apierrors.DecodeCatalogJSON([]byte(raw), &value); err != nil {
|
||||
return err
|
||||
}
|
||||
value, err := durations(value, v.Type())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(encoded))
|
||||
decoder.DisallowUnknownFields()
|
||||
return decoder.Decode(v.Addr().Interface())
|
||||
}
|
||||
return errors.New("unsupported environment value")
|
||||
}
|
||||
|
||||
func envName(s string) string {
|
||||
r := []rune(s)
|
||||
var out strings.Builder
|
||||
for i, c := range r {
|
||||
if i > 0 && unicode.IsUpper(c) && (unicode.IsLower(r[i-1]) || unicode.IsDigit(r[i-1]) || (i+1 < len(r) && unicode.IsLower(r[i+1]))) {
|
||||
out.WriteByte('_')
|
||||
}
|
||||
if c == '-' {
|
||||
c = '_'
|
||||
}
|
||||
out.WriteRune(unicode.ToUpper(c))
|
||||
}
|
||||
return out.String()
|
||||
return errors.New("expected scalar text")
|
||||
}
|
||||
|
||||
@@ -42,20 +42,3 @@ func TestConfigFilePreservesDefaultsWithoutSharingMutableData(t *testing.T) {
|
||||
t.Fatal("decode error leaked contents", err)
|
||||
}
|
||||
}
|
||||
func TestEnvCollectionPrecisionAndDurationMaps(t *testing.T) {
|
||||
type cfg struct {
|
||||
IDs []int64 `json:"ids"`
|
||||
Timers map[string]time.Duration `json:"timers"`
|
||||
}
|
||||
lookup := func(key string) (string, bool) {
|
||||
v, ok := map[string]string{"IDS": "[9007199254740993]", "TIMERS": `{"backoff":"250ms"}`}[key]
|
||||
return v, ok
|
||||
}
|
||||
c, err := OverlayEnv(cfg{}, EnvOptions{Lookup: lookup})
|
||||
if err != nil || c.IDs[0] != 9007199254740993 || c.Timers["backoff"] != 250*time.Millisecond {
|
||||
t.Fatal(c, err)
|
||||
}
|
||||
if _, err = OverlayEnv(cfg{}, EnvOptions{Lookup: func(key string) (string, bool) { return `{"x":"1s","x":"2s"}`, key == "TIMERS" }}); err == nil {
|
||||
t.Fatal("duplicate map key accepted")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type projectNested struct {
|
||||
ReadTimeout time.Duration `json:"readTimeout"`
|
||||
Names []string `json:"names"`
|
||||
}
|
||||
type projectConfig struct {
|
||||
Nested *projectNested `json:"nested"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Count int `json:"count" envDefault:"12"`
|
||||
Password string `json:"-" env:"ETAX_PASSWORD,secret,required,empty=reject"`
|
||||
Label string `json:"label" env:",empty=ignore"`
|
||||
}
|
||||
|
||||
func (c projectConfig) Validate() error {
|
||||
if c.Count < 0 {
|
||||
return errors.New("count must be nonnegative")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func TestProjectConfigurationOrderAndValues(t *testing.T) {
|
||||
f := filepath.Join(t.TempDir(), "config.yaml")
|
||||
if err := os.WriteFile(f, []byte("count: 3\nenabled: true\nnested:\n readTimeout: 2s\n names: [file]\nlabel: keep\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env := map[string]string{"ETAX_PASSWORD": " password ", "APP_ENABLED": "false", "APP_COUNT": "0", "APP_NESTED_READ_TIMEOUT": "7s", "APP_NESTED_NAMES": "[]", "APP_LABEL": ""}
|
||||
options := EnvOptions{Prefix: "APP_", Lookup: func(k string) (string, bool) { v, ok := env[k]; return v, ok }}
|
||||
cfg, err := LoadProject(f, projectConfig{}, options)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Enabled || cfg.Count != 0 || cfg.Password != " password " || cfg.Nested.ReadTimeout != 7*time.Second || cfg.Nested.Names == nil || len(cfg.Nested.Names) != 0 || cfg.Label != "keep" {
|
||||
t.Fatal("overlay mismatch")
|
||||
}
|
||||
delete(env, "APP_COUNT")
|
||||
cfg, err = LoadProject("", projectConfig{}, options)
|
||||
if err != nil || cfg.Count != 12 {
|
||||
t.Fatal(cfg.Count, err)
|
||||
}
|
||||
env["APP_COUNT"] = "-1"
|
||||
if _, err = LoadProject(f, projectConfig{}, options); err == nil {
|
||||
t.Fatal("project Validate not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectOverlayIsTransactionalAndRedacted(t *testing.T) {
|
||||
base := projectConfig{Nested: &projectNested{ReadTimeout: time.Second, Names: []string{"old"}}, Password: "original"}
|
||||
env := map[string]string{"APP_NESTED_READ_TIMEOUT": "2s", "APP_NESTED_NAMES": "[\"new\"]", "APP_COUNT": "secret-value"}
|
||||
_, err := OverlayEnv(base, EnvOptions{Prefix: "APP_", Lookup: func(k string) (string, bool) { v, ok := env[k]; return v, ok }})
|
||||
if err == nil || strings.Contains(err.Error(), "secret-value") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if base.Nested.ReadTimeout != time.Second || !reflect.DeepEqual(base.Nested.Names, []string{"old"}) {
|
||||
t.Fatal("default pointer modified")
|
||||
}
|
||||
delete(env, "APP_COUNT")
|
||||
env["ETAX_PASSWORD"] = ""
|
||||
if _, err = OverlayEnv(base, EnvOptions{Prefix: "APP_", Lookup: func(k string) (string, bool) { v, ok := env[k]; return v, ok }}); err == nil {
|
||||
t.Fatal("empty secret accepted")
|
||||
}
|
||||
type required struct {
|
||||
Token string `env:"TOKEN,required"`
|
||||
}
|
||||
if _, err = OverlayEnv(required{}, EnvOptions{Lookup: func(string) (string, bool) { return "", false }}); err == nil {
|
||||
t.Fatal("required absent")
|
||||
}
|
||||
type duplicate struct {
|
||||
A string `env:"SAME"`
|
||||
B string `env:"SAME"`
|
||||
}
|
||||
if _, err = OverlayEnv(duplicate{}, EnvOptions{}); err == nil {
|
||||
t.Fatal("duplicate env binding accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvironmentCollectionAndEmptySemantics(t *testing.T) {
|
||||
type sample struct {
|
||||
Values []time.Duration
|
||||
Flag bool
|
||||
Text string
|
||||
Optional *int
|
||||
}
|
||||
cases := []struct {
|
||||
raw string
|
||||
valid bool
|
||||
}{{`["1s","2s"]`, true}, {`["invalid-secret"]`, false}}
|
||||
for _, tc := range cases {
|
||||
cfg, err := OverlayEnv(sample{}, EnvOptions{Lookup: func(k string) (string, bool) {
|
||||
if k == "VALUES" {
|
||||
return tc.raw, true
|
||||
}
|
||||
return "", false
|
||||
}})
|
||||
if (err == nil) != tc.valid {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tc.valid && !reflect.DeepEqual(cfg.Values, []time.Duration{time.Second, 2 * time.Second}) {
|
||||
t.Fatal(cfg)
|
||||
}
|
||||
if err != nil && strings.Contains(err.Error(), "secret") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, mode := range []string{"allow", "ignore", "reject"} {
|
||||
cfg, err := OverlayEnv(sample{Text: "old"}, EnvOptions{Empty: mode, Lookup: func(k string) (string, bool) { return "", k == "TEXT" }})
|
||||
if mode == "reject" {
|
||||
if err == nil {
|
||||
t.Fatal("empty not rejected")
|
||||
}
|
||||
} else if err != nil || (mode == "allow" && cfg.Text != "") || (mode == "ignore" && cfg.Text != "old") {
|
||||
t.Fatal(cfg, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
210
config/references.go
Normal file
210
config/references.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// WithLookupEnv supplies an explicit-reference lookup for tests or an embedding
|
||||
// host. Ordinary projects need no loader options. The function must support
|
||||
// concurrent loads; each load caches each referenced name independently.
|
||||
func WithLookupEnv(lookup func(string) (string, bool)) LoaderOption {
|
||||
return func(l *Loader) error {
|
||||
if l == nil || l.sealed || lookup == nil {
|
||||
return errors.New("config: expected a non-nil environment lookup on a new loader")
|
||||
}
|
||||
l.lookup = lookup
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// resolvedText is textual input bound according to the target scalar type. It
|
||||
// never becomes an object, collection or null by interpreting environment data.
|
||||
type resolvedText string
|
||||
|
||||
const maxExpandedBytes = 4 << 20
|
||||
|
||||
type environmentValue struct {
|
||||
value string
|
||||
present bool
|
||||
}
|
||||
type referenceResolver struct {
|
||||
lookup func(string) (string, bool)
|
||||
cache map[string]environmentValue
|
||||
doc Document
|
||||
nodes, bytes, cacheBytes int
|
||||
}
|
||||
|
||||
func resolveDocument(doc Document, lookup func(string) (string, bool)) (Document, error) {
|
||||
r := referenceResolver{lookup: lookup, cache: map[string]environmentValue{}, doc: Document{Positions: doc.Positions, environments: map[string][]string{}}}
|
||||
v, err := r.walk(doc.Value, "", 0)
|
||||
if err != nil {
|
||||
return Document{}, err
|
||||
}
|
||||
r.doc.Value = v
|
||||
return r.doc, nil
|
||||
}
|
||||
func (r *referenceResolver) walk(v any, path string, depth int) (any, error) {
|
||||
r.nodes++
|
||||
if depth > maxConfigDepth || r.nodes > maxConfigNodes {
|
||||
return nil, r.doc.diagnostic(path, "configuration nesting or node limit exceeded", nil)
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return r.text(x, path, false)
|
||||
case Text:
|
||||
return r.text(string(x), path, true)
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(x))
|
||||
for _, key := range sortedKeys(x) {
|
||||
value, err := r.walk(x[key], joinField(path, key), depth+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[key] = value
|
||||
}
|
||||
return out, nil
|
||||
case []any:
|
||||
out := make([]any, len(x))
|
||||
for i, value := range x {
|
||||
var err error
|
||||
out[i], err = r.walk(value, fmt.Sprintf("%s[%d]", path, i), depth+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
default:
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
func (r *referenceResolver) fail(path, name, reason string) error {
|
||||
d := r.doc.diagnostic(path, reason, nil)
|
||||
d.Env = name
|
||||
return d
|
||||
}
|
||||
func validEnvironmentName(name string) bool {
|
||||
if len(name) == 0 {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(name); i++ {
|
||||
c := name[i]
|
||||
if c == '_' || c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' || i > 0 && c >= '0' && c <= '9' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
func (r *referenceResolver) lookupValue(name, path string) (environmentValue, error) {
|
||||
if v, ok := r.cache[name]; ok {
|
||||
return v, nil
|
||||
}
|
||||
value, present := r.lookup(name)
|
||||
if len(value) > maxConfigBytes || r.cacheBytes+len(value) > maxExpandedBytes {
|
||||
return environmentValue{}, r.fail(path, name, "environment value exceeds configuration size limit")
|
||||
}
|
||||
if !utf8.ValidString(value) {
|
||||
return environmentValue{}, r.fail(path, name, "environment value is not valid UTF-8")
|
||||
}
|
||||
|
||||
r.cacheBytes += len(value)
|
||||
v := environmentValue{value, present}
|
||||
r.cache[name] = v
|
||||
return v, nil
|
||||
}
|
||||
func (r *referenceResolver) text(raw, path string, xmlText bool) (any, error) {
|
||||
var out strings.Builder
|
||||
var names []string
|
||||
seen := map[string]bool{}
|
||||
if !strings.Contains(raw, "$") {
|
||||
if len(raw) > maxConfigBytes || r.bytes+len(raw) > maxExpandedBytes {
|
||||
return nil, r.fail(path, "", "expanded configuration exceeds size limit")
|
||||
}
|
||||
r.bytes += len(raw)
|
||||
if xmlText {
|
||||
return Text(raw), nil
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
appendText := func(s string) error {
|
||||
if out.Len()+len(s) > maxConfigBytes || r.bytes+len(s) > maxExpandedBytes {
|
||||
return r.fail(path, strings.Join(names, ", "), "expanded configuration exceeds size limit")
|
||||
}
|
||||
out.WriteString(s)
|
||||
r.bytes += len(s)
|
||||
return nil
|
||||
}
|
||||
// Scan only the file's decoded scalar. Inserted environment values are never
|
||||
// scanned again, including dollars, quotes, separators or newline characters.
|
||||
for i := 0; i < len(raw); {
|
||||
j := strings.IndexByte(raw[i:], '$')
|
||||
if j < 0 {
|
||||
if err := appendText(raw[i:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
break
|
||||
}
|
||||
j += i
|
||||
if err := appendText(raw[i:j]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch {
|
||||
case j+1 < len(raw) && raw[j+1] == '$':
|
||||
if err := appendText("$"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
i = j + 2
|
||||
case j+1 < len(raw) && raw[j+1] == '{':
|
||||
end := strings.IndexByte(raw[j+2:], '}')
|
||||
if end < 0 {
|
||||
return nil, r.fail(path, "", "unclosed environment reference; use ${NAME}, ${NAME:-fallback} or $${...} for a literal")
|
||||
}
|
||||
end += j + 2
|
||||
expression := raw[j+2 : end]
|
||||
name, fallback, hasDefault := strings.Cut(expression, ":-")
|
||||
if !validEnvironmentName(name) || strings.Contains(fallback, "${") {
|
||||
return nil, r.fail(path, "", "invalid environment reference; use ${NAME} or ${NAME:-fallback}; nested references are not supported")
|
||||
}
|
||||
r.nodes++
|
||||
if r.nodes > maxConfigNodes {
|
||||
return nil, r.fail(path, name, "environment reference count exceeds configuration limit")
|
||||
}
|
||||
if !seen[name] {
|
||||
names = append(names, name)
|
||||
seen[name] = true
|
||||
}
|
||||
|
||||
value, err := r.lookupValue(name, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
selected := value.value
|
||||
if !value.present || selected == "" {
|
||||
if !hasDefault {
|
||||
return nil, r.fail(path, name, "required environment variable is unset or empty")
|
||||
}
|
||||
selected = fallback
|
||||
}
|
||||
if err := appendText(selected); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
i = end + 1
|
||||
default:
|
||||
if err := appendText("$"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
i = j + 1
|
||||
}
|
||||
}
|
||||
if len(names) > 0 {
|
||||
r.doc.environments[path] = names
|
||||
return resolvedText(out.String()), nil
|
||||
}
|
||||
if xmlText {
|
||||
return Text(out.String()), nil
|
||||
}
|
||||
return out.String(), nil
|
||||
}
|
||||
230
config/references_test.go
Normal file
230
config/references_test.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type referenceConfig struct {
|
||||
Port int `json:"port"`
|
||||
BatchSize int `json:"batchSize"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Password string `json:"password"`
|
||||
Address string `json:"address"`
|
||||
Timeout time.Duration `json:"timeout"`
|
||||
Optional *int `json:"optional"`
|
||||
Labels map[string]string `json:"labels"`
|
||||
Numbers []int64 `json:"numbers"`
|
||||
Dynamic any `json:"dynamic"`
|
||||
}
|
||||
|
||||
func (c referenceConfig) Validate() error {
|
||||
if c.Port < 0 {
|
||||
return Invalid("port", "must not be negative")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func TestExplicitReferencesAcrossFormats(t *testing.T) {
|
||||
// Delimiters, quotes and newlines belong to the credential, never file syntax.
|
||||
secret := " DDSFLKJlsf:saf:-123\"\n${DO_NOT_READ}&<>\\ "
|
||||
sources := map[string]string{
|
||||
"json": `{"port":"${PORT:-3306}","enabled":"${ENABLED:-true}","password":"${PASSWORD}","address":"https://${HOST}:${PORT:-3306}/v1","timeout":"${TIMEOUT:-3s}","optional":"${ZERO}","numbers":["${BIG}"],"dynamic":"${ZERO}","labels":{"literal":"$${NOT_READ}","empty":"${OPTIONAL:-}","dollars":"$$cost"}}`,
|
||||
"yaml": "port: \"${PORT:-3306}\"\nenabled: \"${ENABLED:-true}\"\npassword: \"${PASSWORD}\"\naddress: \"https://${HOST}:${PORT:-3306}/v1\"\ntimeout: \"${TIMEOUT:-3s}\"\noptional: \"${ZERO}\"\nnumbers: [\"${BIG}\"]\ndynamic: \"${ZERO}\"\nlabels:\n literal: \"$${NOT_READ}\"\n empty: \"${OPTIONAL:-}\"\n dollars: \"$$cost\"\n",
|
||||
"xml": `<config><port>${PORT:-3306}</port><enabled>${ENABLED:-true}</enabled><password>${PASSWORD}</password><address>https://${HOST}:${PORT:-3306}/v1</address><timeout>${TIMEOUT:-3s}</timeout><optional>${ZERO}</optional><numbers type="array"><item>${BIG}</item></numbers><dynamic>${ZERO}</dynamic><labels><literal>$${NOT_READ}</literal><empty>${OPTIONAL:-}</empty><dollars>$$cost</dollars></labels></config>`,
|
||||
}
|
||||
for ext, source := range sources {
|
||||
t.Run(ext, func(t *testing.T) {
|
||||
calls := map[string]int{}
|
||||
values := map[string]string{"PORT": "0", "ENABLED": "false", "PASSWORD": secret, "HOST": "example.test", "ZERO": "0", "BIG": "9007199254740993"}
|
||||
l := testLoader(func(name string) (string, bool) { calls[name]++; v, ok := values[name]; return v, ok })
|
||||
got, err := LoadProject(configFile(t, ext, source), referenceConfig{Port: 77, BatchSize: 100, Enabled: true, Password: "old"}, l)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Port != 0 || got.BatchSize != 100 || got.Enabled || got.Password != secret || got.Address != "https://example.test:0/v1" || got.Timeout != 3*time.Second || got.Optional == nil || *got.Optional != 0 || got.Numbers[0] != 9007199254740993 || got.Dynamic != "0" {
|
||||
t.Fatal("binding changed values")
|
||||
}
|
||||
if got.Labels["literal"] != "${NOT_READ}" || got.Labels["empty"] != "" || got.Labels["dollars"] != "$cost" {
|
||||
t.Fatal(got.Labels)
|
||||
}
|
||||
for name, n := range calls {
|
||||
if n != 1 {
|
||||
t.Fatal("not cached", name, n)
|
||||
}
|
||||
}
|
||||
if calls["DO_NOT_READ"] != 0 || calls["NOT_READ"] != 0 {
|
||||
t.Fatal("recursive expansion")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestReferenceFallbackAndFixedValues(t *testing.T) {
|
||||
for _, present := range []bool{false, true} {
|
||||
l := testLoader(func(string) (string, bool) { return "", present })
|
||||
got, err := LoadProject(configFile(t, "yaml", "port: \"${PORT:-3306}\"\npassword: \"${PASSWORD:-DDSFLKJlsf:saf:-123}\""), referenceConfig{}, l)
|
||||
if err != nil || got.Port != 3306 || got.Password != "DDSFLKJlsf:saf:-123" {
|
||||
t.Fatal(got, err)
|
||||
}
|
||||
for _, ext := range []string{"json", "yaml", "xml"} {
|
||||
raw := `{"password":"${PASSWORD}"}`
|
||||
if ext == "yaml" {
|
||||
raw = "password: '${PASSWORD}'"
|
||||
}
|
||||
if ext == "xml" {
|
||||
raw = "<config><password>${PASSWORD}</password></config>"
|
||||
}
|
||||
path := configFile(t, ext, raw)
|
||||
_, err = LoadProject(path, referenceConfig{Password: "initial"}, l)
|
||||
var d *Error
|
||||
if !errors.As(err, &d) || d.Env != "PASSWORD" || d.File != path || d.Field != "password" || d.Line < 1 {
|
||||
t.Fatal("missing required-reference diagnostic", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
calls := 0
|
||||
l := testLoader(func(string) (string, bool) { calls++; return "999", true })
|
||||
got, err := LoadProject(configFile(t, "json", `{"port":0,"enabled":false,"password":""}`), referenceConfig{Port: 77, BatchSize: 100, Enabled: true, Password: "old"}, l)
|
||||
if err != nil || got.Port != 0 || got.BatchSize != 100 || got.Enabled || got.Password != "" || calls != 0 {
|
||||
t.Fatal(got, err, calls)
|
||||
}
|
||||
got, err = LoadProject(configFile(t, "yaml", "password: '${VALUE}'"), referenceConfig{}, testLoader(func(string) (string, bool) { return " ", true }))
|
||||
if err != nil || got.Password != " " {
|
||||
t.Fatal("trimmed credential", err)
|
||||
}
|
||||
}
|
||||
func TestReferenceDiagnosticsAndScalarLimits(t *testing.T) {
|
||||
for _, value := range []string{"private-not-int", "92233720368547758080", "false", "null", "-1"} {
|
||||
path := configFile(t, "yaml", "port: '${PORT}'")
|
||||
_, err := LoadProject(path, referenceConfig{}, testLoader(func(string) (string, bool) { return value, true }))
|
||||
var d *Error
|
||||
if !errors.As(err, &d) || d.File != path || d.Env != "PORT" || d.Field != "port" || d.Line != 1 || d.Column < 1 || strings.Contains(err.Error(), value) {
|
||||
t.Fatalf("invalid diagnostic: %v", err)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{"${BAD-123}", "${}", "${X", "${1BAD}", "${BAD NAME}", "${X:-${Y}}"} {
|
||||
encoded, _ := json.Marshal(map[string]string{"password": raw})
|
||||
if _, err := LoadProject(configFile(t, "json", string(encoded)), referenceConfig{}, testLoader(noEnvironment)); err == nil {
|
||||
t.Fatal("invalid syntax accepted", raw)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{string([]byte{0xff}), strings.Repeat("x", maxConfigBytes+1)} {
|
||||
if _, err := LoadProject(configFile(t, "yaml", "password: '${SECRET}'"), referenceConfig{}, testLoader(func(string) (string, bool) { return value, true })); err == nil {
|
||||
t.Fatal("unbounded/invalid environment")
|
||||
}
|
||||
}
|
||||
path := configFile(t, "yaml", "labels:\n a: '${BIG}'\n b: '${BIG}'\n c: '${BIG}'\n d: '${BIG}'\n e: '${BIG}'")
|
||||
if _, err := LoadProject(path, referenceConfig{}, testLoader(func(string) (string, bool) { return strings.Repeat("x", maxConfigBytes), true })); err == nil {
|
||||
t.Fatal("expansion budget bypassed")
|
||||
}
|
||||
// An environment value cannot inject an array/object or coerce dynamic null.
|
||||
for _, raw := range []string{"[1,2]", "{\"x\":1}", "null"} {
|
||||
_, err := LoadProject(configFile(t, "yaml", "numbers: '${VALUE}'"), referenceConfig{}, testLoader(func(string) (string, bool) { return raw, true }))
|
||||
if err == nil {
|
||||
t.Fatal("structure injected")
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestReferenceCacheIsolationAndRemovedRules(t *testing.T) {
|
||||
path := configFile(t, "yaml", "password: '${TOKEN}'")
|
||||
for i := 0; i < 24; i++ {
|
||||
t.Run(fmt.Sprint(i), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
v := fmt.Sprint(i)
|
||||
l := testLoader(func(string) (string, bool) { return v, true })
|
||||
for n := 0; n < 4; n++ {
|
||||
got, err := LoadProject(path, referenceConfig{}, l)
|
||||
if err != nil || got.Password != v {
|
||||
t.Fatal("load leaked", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestReferenceLookupPerLoadAndConcurrentLoader(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
reads := 0
|
||||
l := testLoader(func(string) (string, bool) { mu.Lock(); defer mu.Unlock(); reads++; return "secret", true })
|
||||
path := configFile(t, "yaml", "password: '${TOKEN}${TOKEN}'")
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 24; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
got, err := LoadProject(path, referenceConfig{}, l)
|
||||
if err != nil || got.Password != "secretsecret" {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if reads != 24 {
|
||||
t.Fatal(reads)
|
||||
}
|
||||
}
|
||||
func TestRemovedEnvTagsAndFieldSuggestion(t *testing.T) {
|
||||
type old struct {
|
||||
Value int `json:"value" envDefault:"9"`
|
||||
}
|
||||
if _, err := LoadProject("", old{}); err == nil || !strings.Contains(err.Error(), "removed") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
type oldSecret struct {
|
||||
Token string `json:"-" env:"TOKEN"`
|
||||
}
|
||||
if _, err := DecodeFile(configFile(t, "json", `{}`), oldSecret{}); err == nil {
|
||||
t.Fatal("old env tag ignored")
|
||||
}
|
||||
_, err := LoadProject(configFile(t, "yaml", "batchSiz: 10"), referenceConfig{})
|
||||
if err == nil || !strings.Contains(err.Error(), `did you mean "batchSize"`) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("GO_OMNI_BATCH_SIZE", "999")
|
||||
got, err := LoadProject("", referenceConfig{BatchSize: 100})
|
||||
if err != nil || got.BatchSize != 100 {
|
||||
t.Fatal("implicit overlay", err)
|
||||
}
|
||||
}
|
||||
func FuzzConfigurationReferences(f *testing.F) {
|
||||
for _, s := range []string{"${X}", "$${X}", "${X:-DDS:abc:-123}", "${X:-}", "https://${X}/${X}", "${X:-${Y}}"} {
|
||||
f.Add(s, " secret${NO}\n\" ")
|
||||
}
|
||||
f.Fuzz(func(t *testing.T, raw, value string) {
|
||||
if len(raw) > 8192 || len(value) > 8192 {
|
||||
return
|
||||
}
|
||||
doc := Document{Value: map[string]any{"password": raw}}
|
||||
resolved, err := resolveDocument(doc, func(string) (string, bool) { return value, true })
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, _ = bindDocument(resolved, referenceConfig{})
|
||||
})
|
||||
}
|
||||
func BenchmarkExplicitConfiguration(b *testing.B) {
|
||||
for _, refs := range []bool{false, true} {
|
||||
b.Run(fmt.Sprint(refs), func(b *testing.B) {
|
||||
source := `{"port":3306,"enabled":false,"password":"fixed","batchSize":0}`
|
||||
if refs {
|
||||
source = `{"port":"${PORT:-3306}","enabled":"${ENABLED:-false}","password":"${PASSWORD}","batchSize":0}`
|
||||
}
|
||||
path := b.TempDir() + "/config.json"
|
||||
if err := os.WriteFile(path, []byte(source), 0600); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
l := testLoader(func(k string) (string, bool) { return "private", k == "PASSWORD" })
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := LoadProject(path, referenceConfig{BatchSize: 100}, l); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
51
config/suggestions.go
Normal file
51
config/suggestions.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Suggest only a unique, nearby declared field. Input values are never used.
|
||||
func unknownFieldReason(name string, fields map[string]reflect.StructField) string {
|
||||
if len(name) < 3 || len(name) > 128 {
|
||||
return "unknown field"
|
||||
}
|
||||
best, match, tied := 3, "", false
|
||||
for candidate := range fields {
|
||||
if len(candidate) > 128 {
|
||||
continue
|
||||
}
|
||||
d := fieldDistance(strings.ToLower(name), strings.ToLower(candidate))
|
||||
if d < best {
|
||||
best, match, tied = d, candidate, false
|
||||
} else if d == best {
|
||||
tied = true
|
||||
}
|
||||
}
|
||||
if match != "" && !tied && best <= 2 {
|
||||
return "unknown field; did you mean " + strconv.Quote(match) + "?"
|
||||
}
|
||||
return "unknown field"
|
||||
}
|
||||
func fieldDistance(a, b string) int {
|
||||
prev := make([]int, len(b)+1)
|
||||
for i := range prev {
|
||||
prev[i] = i
|
||||
}
|
||||
for i := 0; i < len(a); i++ {
|
||||
left, diagonal := i+1, prev[0]
|
||||
prev[0] = left
|
||||
for j := 0; j < len(b); j++ {
|
||||
cost := 0
|
||||
if a[i] != b[j] {
|
||||
cost = 1
|
||||
}
|
||||
old := prev[j+1]
|
||||
left = min(left+1, old+1, diagonal+cost)
|
||||
prev[j+1] = left
|
||||
diagonal = old
|
||||
}
|
||||
}
|
||||
return prev[len(b)]
|
||||
}
|
||||
Reference in New Issue
Block a user