feat: add project-owned error catalogs and HTTP 200 failure contracts

This commit is contained in:
2026-09-24 12:40:05 +08:00
parent bb2caefbb4
commit ebfd279f9a
42 changed files with 1897 additions and 120 deletions

View File

@@ -4,6 +4,7 @@ import (
"strings"
"time"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/logx"
"git.yuntuo.hk/k2safe/go-omni/response"
@@ -25,18 +26,19 @@ 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"`
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"`
Response response.Config `json:"response"`
ResponseEncoder response.Encoder `json:"-"`
Address string `json:"address"`
Transport string `json:"transport"`
TLS TLSConfig `json:"tls"`
HTTP HTTPConfig `json:"http"`
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:"-"`
}
func Default() Config {
@@ -51,6 +53,18 @@ func Default() Config {
// Validate checks configuration values before an application starts.
func (c Config) Validate() error {
if err := c.Errors.Validate(); err != nil {
return err
}
if c.ResponseEncoder == nil {
f, err := response.New(c.Response)
if err != nil {
return err
}
if err = f.ValidateErrors(c.Errors); err != nil {
return err
}
}
if err := c.Response.Validate(); err != nil {
return err
}
@@ -151,11 +165,13 @@ func WithTrustedProxies(proxies ...string) Option {
// environment. Later options can still override individual fields.
func WithConfig(settings Config) Option {
settings.Response = settings.Response.Clone()
settings.Errors = settings.Errors.Clone()
settings.TrustedProxies = append([]string(nil), settings.TrustedProxies...)
settings.Log.RedactKeys = append([]string(nil), settings.Log.RedactKeys...)
return func(cfg *Config) {
*cfg = settings
cfg.Response = settings.Response.Clone()
cfg.Errors = settings.Errors.Clone()
cfg.TrustedProxies = append([]string(nil), settings.TrustedProxies...)
cfg.Log.RedactKeys = append([]string(nil), settings.Log.RedactKeys...)
}
@@ -190,3 +206,9 @@ func WithShutdownTimeout(timeout time.Duration) Option {
}
}
}
// WithErrors installs a project-owned, app-local catalog.
func WithErrors(catalog apierrors.Catalog) Option {
catalog = catalog.Clone()
return func(cfg *Config) { cfg.Errors = catalog.Clone() }
}

View File

@@ -19,6 +19,7 @@ func LoadFromEnv(prefix string) (Config, error) {
// ApplyEnv overlays nonempty environment values on a caller's defaults or file
// settings, then validates the result. Caller-owned slices are not retained.
func ApplyEnv(defaults Config, prefix string) (Config, error) {
defaults.Errors = defaults.Errors.Clone()
defaults.TrustedProxies = append([]string(nil), defaults.TrustedProxies...)
defaults.Log.RedactKeys = append([]string(nil), defaults.Log.RedactKeys...)
return applyEnv(defaults, prefix)

View File

@@ -0,0 +1,75 @@
package config_test
import (
"os"
"path/filepath"
"strings"
"testing"
"git.yuntuo.hk/k2safe/go-omni/config"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/logx"
)
func TestCatalogConfigFiles(t *testing.T) {
for _, file := range []struct{ name, body string }{
{"server.json", `{"response":{"codeType":"int64","successCode":0,"errorCode":9999},"errors":{"defaultStatus":200,"definitions":{"LIMIT":{"code":9223372036854775807,"message":"limit"}}}}`},
{"server.yaml", "response:\n codeType: int64\n successCode: 0\n errorCode: 9999\nerrors:\n defaultStatus: 200\n definitions:\n LIMIT:\n code: 9223372036854775807\n message: limit\n"},
} {
t.Run(file.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), file.name)
if err := os.WriteFile(path, []byte(file.body), 0600); err != nil {
t.Fatal(err)
}
cfg, err := config.LoadFile(path, "CATALOG_FIXTURE_")
if err != nil {
t.Fatal(err)
}
e := apierrors.MustRegistry(cfg.Errors).Resolve(apierrors.ByCode("LIMIT"))
if e.Status != 200 || e.BusinessCode != int64(9223372036854775807) {
t.Fatal(e)
}
})
}
for _, body := range []string{
`{"errors":{"definitions":{"A":{"message":"a","code":1},"A":{"message":"b","code":2}}}}`,
`{"response":{"codeType":"int32","successCode":0,"errorCode":99},"errors":{"definitions":{"A":{"message":"a","code":2147483648}}}}`,
`{"errors":{"definitions":{"A":{"message":"a","code":"a","status":204}}}}`,
} {
path := filepath.Join(t.TempDir(), "bad.json")
if err := os.WriteFile(path, []byte(body), 0600); err != nil {
t.Fatal(err)
}
if _, err := config.LoadFile(path, "CATALOG_FIXTURE_"); err == nil {
t.Fatal("accepted invalid catalog", body)
}
}
}
func TestCatalogReloadSnapshotIsolation(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
original := `{"errors":{"defaultStatus":200,"definitions":{"invalid":{"code":"invalid","message":"original"}}}}`
if err := os.WriteFile(path, []byte(original), 0600); err != nil {
t.Fatal(err)
}
logger, err := logx.New(logx.Default())
if err != nil {
t.Fatal(err)
}
defer logger.Close()
reloader, err := config.NewReloader(path, "CATALOG_RELOAD_", logger)
if err != nil {
t.Fatal(err)
}
snapshot := reloader.Snapshot()
snapshot.Errors.Definitions["invalid"] = apierrors.Definition{Message: "changed"}
if reloader.Snapshot().Errors.Definitions["invalid"].Message != "original" {
t.Fatal("snapshot changed live catalog")
}
if err := os.WriteFile(path, []byte(strings.Replace(original, "original", "new message", 1)), 0600); err != nil {
t.Fatal(err)
}
if err := reloader.Reload(); err == nil || reloader.Snapshot().Errors.Definitions["invalid"].Message != "original" {
t.Fatal("catalog changed without restart", err)
}
}

View File

@@ -5,8 +5,6 @@ import (
"context"
"encoding/json"
"errors"
"git.yuntuo.hk/k2safe/go-omni/logx"
"gopkg.in/yaml.v3"
"io"
"os"
"path/filepath"
@@ -14,6 +12,10 @@ import (
"strings"
"sync"
"time"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/logx"
"gopkg.in/yaml.v3"
)
// DecodeFile merges a bounded JSON/YAML document into defaults. It uses JSON
@@ -45,15 +47,9 @@ func DecodeFile[T any](path string, defaults T) (T, error) {
return zero, errors.New("config: one YAML document required")
}
case ".json":
dec := json.NewDecoder(bytes.NewReader(data))
dec.UseNumber()
if err := dec.Decode(&value); err != nil {
if err := apierrors.DecodeCatalogJSON(data, &value); err != nil {
return zero, err
}
var extra any
if err := dec.Decode(&extra); err != io.EOF {
return zero, errors.New("config: one JSON document required")
}
default:
return zero, errors.New("config: expected .json, .yaml or .yml")
}
@@ -182,6 +178,7 @@ func (r *Reloader) Snapshot() Config {
defer r.mu.Unlock()
cfg := r.current
cfg.Response = r.current.Response.Clone()
cfg.Errors = r.current.Errors.Clone()
cfg.TrustedProxies = append([]string(nil), cfg.TrustedProxies...)
cfg.Log.RedactKeys = append([]string(nil), cfg.Log.RedactKeys...)
return cfg