feat: complete contract-driven business development and MySQL integration

This commit is contained in:
2026-09-24 21:56:04 +08:00
parent f9afb8f8dd
commit f5478cdc76
108 changed files with 6557 additions and 550 deletions

81
config/clone.go Normal file
View File

@@ -0,0 +1,81 @@
package config
import (
"errors"
"reflect"
"strings"
)
// Clone only configuration data. json:"-" capabilities remain borrowed and
// are never traversed (log writers, clients and mutex-bearing runtime objects).
// Unexported immutable implementation details such as time.Time are copied.
func cloneConfiguration(v reflect.Value, depth int) (reflect.Value, error) {
if depth > 64 {
return reflect.Value{}, errors.New("config: defaults contain cycles or excessive nesting")
}
result := reflect.New(v.Type()).Elem()
result.Set(v)
switch v.Kind() {
case reflect.Struct:
for i := 0; i < v.NumField(); i++ {
field := v.Type().Field(i)
if field.PkgPath != "" || strings.Split(field.Tag.Get("json"), ",")[0] == "-" {
continue
}
child, err := cloneConfiguration(v.Field(i), depth+1)
if err != nil {
return reflect.Value{}, err
}
result.Field(i).Set(child)
}
case reflect.Pointer:
if !v.IsNil() {
child, err := cloneConfiguration(v.Elem(), depth+1)
if err != nil {
return reflect.Value{}, err
}
result.Set(reflect.New(v.Type().Elem()))
result.Elem().Set(child)
}
case reflect.Interface:
if !v.IsNil() {
child, err := cloneConfiguration(v.Elem(), depth+1)
if err != nil {
return reflect.Value{}, err
}
result.Set(child)
}
case reflect.Slice:
if !v.IsNil() {
result = reflect.MakeSlice(v.Type(), v.Len(), v.Len())
for i := 0; i < v.Len(); i++ {
child, err := cloneConfiguration(v.Index(i), depth+1)
if err != nil {
return reflect.Value{}, err
}
result.Index(i).Set(child)
}
}
case reflect.Array:
for i := 0; i < v.Len(); i++ {
child, err := cloneConfiguration(v.Index(i), depth+1)
if err != nil {
return reflect.Value{}, err
}
result.Index(i).Set(child)
}
case reflect.Map:
if !v.IsNil() {
result = reflect.MakeMapWithSize(v.Type(), v.Len())
iter := v.MapRange()
for iter.Next() {
child, err := cloneConfiguration(iter.Value(), depth+1)
if err != nil {
return reflect.Value{}, err
}
result.SetMapIndex(iter.Key(), child)
}
}
}
return result, nil
}

View File

@@ -4,6 +4,7 @@ import (
"strings"
"time"
"git.yuntuo.hk/k2safe/go-omni/contract"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/logx"
@@ -26,20 +27,22 @@ type TLSConfig struct {
// Config controls the default runtime behavior of an App.
type Config struct {
Address string `json:"address"`
Transport string `json:"transport"`
TLS TLSConfig `json:"tls"`
HTTP HTTPConfig `json:"http"`
Endpoints EndpointsConfig `json:"endpoints"`
RequestIDHeader string `json:"requestIdHeader"`
EnableLogger bool `json:"enableLogger"`
SlowRequestThreshold time.Duration `json:"slowRequestThreshold"`
TrustedProxies []string `json:"trustedProxies"`
ShutdownTimeout time.Duration `json:"shutdownTimeout"`
Log logx.Config `json:"log"`
Errors apierrors.Catalog `json:"errors"`
Response response.Config `json:"response"`
ResponseEncoder response.Encoder `json:"-"`
Request contract.RequestPolicy `json:"request"`
Address string `json:"address"`
Transport string `json:"transport"`
TLS TLSConfig `json:"tls"`
HTTP HTTPConfig `json:"http"`
Endpoints EndpointsConfig `json:"endpoints"`
RequestIDHeader string `json:"requestIdHeader"`
EnableLogger bool `json:"enableLogger"`
SlowRequestThreshold time.Duration `json:"slowRequestThreshold"`
TrustedProxies []string `json:"trustedProxies"`
ShutdownTimeout time.Duration `json:"shutdownTimeout"`
Log logx.Config `json:"log"`
Errors apierrors.Catalog `json:"errors"`
Response response.Config `json:"response"`
ResponseEncoder response.Encoder `json:"-"`
LogInstance *logx.Logger `json:"-"`
}
func Default() Config {
@@ -55,6 +58,12 @@ func Default() Config {
// Validate checks configuration values before an application starts.
func (c Config) Validate() error {
if err := c.Request.Validate(); err != nil {
return err
}
if err := c.Log.Validate(); err != nil {
return err
}
if err := c.Endpoints.Validate(); err != nil {
return err
}
@@ -221,3 +230,10 @@ func WithErrors(catalog apierrors.Catalog) Option {
catalog = catalog.Clone()
return func(cfg *Config) { cfg.Errors = catalog.Clone() }
}
// WithLogInstance borrows a logger for this App without changing process logging.
func WithLogInstance(logger *logx.Logger) Option { return func(c *Config) { c.LogInstance = logger } }
func WithRequestPolicy(policy contract.RequestPolicy) Option {
return func(c *Config) { c.Request = policy }
}

View File

@@ -22,6 +22,23 @@ import (
// 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 {
@@ -64,15 +81,13 @@ func DecodeFile[T any](path string, defaults T) (T, error) {
if err != nil {
return zero, err
}
// Deep copy defaults so errors cannot partially mutate caller-owned maps.
original, err := json.Marshal(defaults)
// Clone configuration without serializing borrowed json:"-" capabilities.
var result T
original, err := cloneConfiguration(reflect.ValueOf(&defaults).Elem(), 0)
if err != nil {
return zero, err
}
var result T
if err = json.Unmarshal(original, &result); 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 {
@@ -115,6 +130,17 @@ func durations(v any, t reflect.Type) (any, error) {
}
}
}
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 {

290
config/project.go Normal file
View File

@@ -0,0 +1,290 @@
package config
import (
"bytes"
"encoding"
"encoding/json"
"errors"
"fmt"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"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)
}
// 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) (T, error) {
var zero T
cfg, err := projectOverlay(defaults, options, true)
if err != nil {
return zero, err
}
if path != "" {
cfg, err = DecodeFile(path, cfg)
if err != nil {
return zero, errors.New("config: cannot decode file; check its path, format, field names and value types")
}
}
cfg, err = OverlayEnv(cfg, options)
if err != nil {
return zero, err
}
if valid, ok := any(&cfg).(interface{ Validate() error }); ok {
if err = valid.Validate(); err != nil {
return zero, err
}
}
return cfg, nil
}
// 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)
}
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) {
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, int) error
walk = func(value reflect.Value, prefix 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
}
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+"_", 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+"_", 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)
}
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
raw, present := options.Lookup(key)
if defaults {
raw, present = field.Tag.Lookup("envDefault")
present = present && dst.IsZero()
}
if present && raw == "" && !defaults {
if empty == "reject" {
return fmt.Errorf("config: %s must not be empty", key)
}
if empty == "ignore" {
present = false
}
}
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", key, dst.Type())
}
dst.Set(parsed)
}
if required && !defaults && emptyRequired(dst) {
return fmt.Errorf("config: %s is required", key)
}
}
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) {
defer func() {
if recover() != nil {
err = errors.New("invalid custom value")
}
}()
if v.Kind() == reflect.Pointer {
v.Set(reflect.New(v.Type().Elem()))
return parseEnvValue(v.Elem(), raw)
}
if v.CanAddr() && v.Addr().Type().Implements(textType) {
return v.Addr().Interface().(encoding.TextUnmarshaler).UnmarshalText([]byte(raw))
}
if v.Type() == durationType {
d, err := time.ParseDuration(raw)
if err == nil {
v.SetInt(int64(d))
}
return err
}
switch v.Kind() {
case reflect.String:
v.SetString(raw)
return nil
case reflect.Bool:
x, err := strconv.ParseBool(raw)
if err == nil {
v.SetBool(x)
}
return err
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
x, err := strconv.ParseInt(raw, 10, v.Type().Bits())
if err == nil {
v.SetInt(x)
}
return err
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
x, err := strconv.ParseUint(raw, 10, v.Type().Bits())
if err == nil {
v.SetUint(x)
}
return err
case reflect.Float32, reflect.Float64:
x, err := strconv.ParseFloat(raw, v.Type().Bits())
if err == nil && (math.IsNaN(x) || math.IsInf(x, 0)) {
return errors.New("nonfinite value")
}
if err == nil {
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()
}

View File

@@ -0,0 +1,61 @@
package config
import (
"bytes"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestConfigFilePreservesDefaultsWithoutSharingMutableData(t *testing.T) {
type cfg struct {
Writer io.Writer `json:"-"`
Secret string `json:"-"`
Values map[string]int64 `json:"values"`
Timers map[string]time.Duration `json:"timers"`
ID int64 `json:"id"`
}
var writer bytes.Buffer
base := cfg{Writer: &writer, Secret: "borrowedSecret", Values: map[string]int64{"old": 7}, ID: 9007199254740993}
file := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(file, []byte(`{"values":{"new":2},"timers":{"retry":"2s"}}`), 0600); err != nil {
t.Fatal(err)
}
next, err := DecodeFile(file, base)
if err != nil {
t.Fatal(err)
}
if next.Writer != &writer || next.Secret != base.Secret || next.ID != base.ID || next.Timers["retry"] != 2*time.Second || len(base.Values) != 1 {
t.Fatal("defaults lost or mutated")
}
next.Values["old"] = 9
if base.Values["old"] != 7 {
t.Fatal("defaults shared map")
}
if err = os.WriteFile(file, []byte(`{"values":{"secretValue":"credential-super-secret"}}`), 0600); err != nil {
t.Fatal(err)
}
if _, err = DecodeFile(file, base); err == nil || strings.Contains(err.Error(), "credential-super-secret") {
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")
}
}

124
config/project_test.go Normal file
View File

@@ -0,0 +1,124 @@
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)
}
}
}