refactor(serialization): generalize unmarshal/load functions with pluggable format handlers

Replace YAML-specific functions with generic ones accepting unmarshaler/marshaler
function parameters. This enables future support for JSON and other formats
while maintaining current YAML behavior.

- UnmarshalValidateYAML -> UnmarshalValidate(unmarshalFunc)
- UnmarshalValidateYAMLXSync -> UnmarshalValidateXSync(unmarshalFunc)
- SaveJSON -> SaveFile(marshalFunc)
- LoadJSONIfExist -> LoadFileIfExist(unmarshalFunc)
- Add UnmarshalValidateReader for reader-based decoding

Testing: all 12 staged test files updated to use new API
This commit is contained in:
yusing
2026-01-29 11:57:32 +08:00
parent 32971b45c3
commit a75441aa8a
12 changed files with 119 additions and 58 deletions

View File

@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"testing" "testing"
"github.com/goccy/go-yaml"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/yusing/godoxy/internal/autocert" "github.com/yusing/godoxy/internal/autocert"
"github.com/yusing/godoxy/internal/dnsproviders" "github.com/yusing/godoxy/internal/dnsproviders"
@@ -25,9 +26,9 @@ func TestEABConfigRequired(t *testing.T) {
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
yaml := fmt.Appendf(nil, "eab_kid: %s\neab_hmac: %s", test.cfg.EABKid, test.cfg.EABHmac) yamlCfg := fmt.Appendf(nil, "eab_kid: %s\neab_hmac: %s", test.cfg.EABKid, test.cfg.EABHmac)
cfg := autocert.Config{} cfg := autocert.Config{}
err := serialization.UnmarshalValidateYAML(yaml, &cfg) err := serialization.UnmarshalValidate(yamlCfg, &cfg, yaml.Unmarshal)
if (err != nil) != test.wantErr { if (err != nil) != test.wantErr {
t.Errorf("Validate() error = %v, wantErr %v", err, test.wantErr) t.Errorf("Validate() error = %v, wantErr %v", err, test.wantErr)
} }

View File

@@ -6,6 +6,7 @@ import (
"os" "os"
"testing" "testing"
"github.com/goccy/go-yaml"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/yusing/godoxy/internal/autocert" "github.com/yusing/godoxy/internal/autocert"
"github.com/yusing/godoxy/internal/serialization" "github.com/yusing/godoxy/internal/serialization"
@@ -41,7 +42,7 @@ func TestMultipleCertificatesLifecycle(t *testing.T) {
cfg.HTTPClient = acmeServer.httpClient() cfg.HTTPClient = acmeServer.httpClient()
/* unmarshal yaml config with multiple certs */ /* unmarshal yaml config with multiple certs */
err := error(serialization.UnmarshalValidateYAML(yamlConfig, &cfg)) err := error(serialization.UnmarshalValidate(yamlConfig, &cfg, yaml.Unmarshal))
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, []string{"main.example.com"}, cfg.Domains) require.Equal(t, []string{"main.example.com"}, cfg.Domains)
require.Len(t, cfg.Extra, 2) require.Len(t, cfg.Extra, 2)

View File

@@ -3,6 +3,7 @@ package autocert_test
import ( import (
"testing" "testing"
"github.com/goccy/go-yaml"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/yusing/godoxy/internal/autocert" "github.com/yusing/godoxy/internal/autocert"
"github.com/yusing/godoxy/internal/dnsproviders" "github.com/yusing/godoxy/internal/dnsproviders"
@@ -42,7 +43,7 @@ extra:
` `
var cfg autocert.Config var cfg autocert.Config
err := error(serialization.UnmarshalValidateYAML([]byte(cfgYAML), &cfg)) err := error(serialization.UnmarshalValidate([]byte(cfgYAML), &cfg, yaml.Unmarshal))
require.NoError(t, err) require.NoError(t, err)
// Test: extra[0] inherits all fields from main except CertPath and KeyPath. // Test: extra[0] inherits all fields from main except CertPath and KeyPath.

View File

@@ -103,7 +103,7 @@ func (state *state) InitFromFile(filename string) error {
} }
func (state *state) Init(data []byte) error { func (state *state) Init(data []byte) error {
err := serialization.UnmarshalValidateYAML(data, &state.Config) err := serialization.UnmarshalValidate(data, &state.Config, yaml.Unmarshal)
if err != nil { if err != nil {
return err return err
} }

View File

@@ -4,6 +4,7 @@ import (
"regexp" "regexp"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/goccy/go-yaml"
"github.com/yusing/godoxy/agent/pkg/agent" "github.com/yusing/godoxy/agent/pkg/agent"
"github.com/yusing/godoxy/internal/acl" "github.com/yusing/godoxy/internal/acl"
"github.com/yusing/godoxy/internal/autocert" "github.com/yusing/godoxy/internal/autocert"
@@ -43,7 +44,7 @@ type (
func Validate(data []byte) gperr.Error { func Validate(data []byte) gperr.Error {
var model Config var model Config
return serialization.UnmarshalValidateYAML(data, &model) return serialization.UnmarshalValidate(data, &model, yaml.Unmarshal)
} }
func DefaultConfig() Config { func DefaultConfig() Config {

View File

@@ -55,20 +55,20 @@ func init() {
func InitCache() { func InitCache() {
m := make(IconMap) m := make(IconMap)
err := serialization.LoadJSONIfExist(common.IconListCachePath, &m) err := serialization.LoadFileIfExist(common.IconListCachePath, &m, sonic.Unmarshal)
if err != nil { if err != nil {
// backward compatible // backward compatible
oldFormat := struct { oldFormat := struct {
Icons IconMap Icons IconMap
LastUpdate time.Time LastUpdate time.Time
}{} }{}
err = serialization.LoadJSONIfExist(common.IconListCachePath, &oldFormat) err = serialization.LoadFileIfExist(common.IconListCachePath, &oldFormat, sonic.Unmarshal)
if err != nil { if err != nil {
log.Error().Err(err).Msg("failed to load icons") log.Error().Err(err).Msg("failed to load icons")
} else { } else {
m = oldFormat.Icons m = oldFormat.Icons
// store it to disk immediately // store it to disk immediately
_ = serialization.SaveJSON(common.IconListCachePath, &m, 0o644) _ = serialization.SaveFile(common.IconListCachePath, &m, 0o644, sonic.Marshal)
} }
} else if len(m) > 0 { } else if len(m) > 0 {
log.Info(). log.Info().
@@ -84,7 +84,7 @@ func InitCache() {
task.OnProgramExit("save_icons_cache", func() { task.OnProgramExit("save_icons_cache", func() {
icons := iconsCache.Load() icons := iconsCache.Load()
_ = serialization.SaveJSON(common.IconListCachePath, &icons, 0o644) _ = serialization.SaveFile(common.IconListCachePath, &icons, 0o644, sonic.Marshal)
}) })
go backgroundUpdateIcons() go backgroundUpdateIcons()
@@ -105,7 +105,7 @@ func backgroundUpdateIcons() {
// swap old cache with new cache // swap old cache with new cache
iconsCache.Store(newCache) iconsCache.Store(newCache)
// save it to disk // save it to disk
err := serialization.SaveJSON(common.IconListCachePath, &newCache, 0o644) err := serialization.SaveFile(common.IconListCachePath, &newCache, 0o644, sonic.Marshal)
if err != nil { if err != nil {
log.Warn().Err(err).Msg("failed to save icons") log.Warn().Err(err).Msg("failed to save icons")
} }

View File

@@ -82,7 +82,8 @@ func loadNS[T store](ns namespace) T {
func save() error { func save() error {
errs := gperr.NewBuilder("failed to save data stores") errs := gperr.NewBuilder("failed to save data stores")
for ns, store := range stores { for ns, store := range stores {
if err := serialization.SaveJSON(filepath.Join(storesPath, string(ns)+".json"), &store, 0o644); err != nil { path := filepath.Join(storesPath, string(ns)+".json")
if err := serialization.SaveFile(path, &store, 0o644, sonic.Marshal); err != nil {
errs.Add(err) errs.Add(err)
} }
} }

View File

@@ -3,6 +3,7 @@ package proxmox
import ( import (
"testing" "testing"
"github.com/goccy/go-yaml"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/yusing/godoxy/internal/serialization" "github.com/yusing/godoxy/internal/serialization"
) )
@@ -43,7 +44,7 @@ func TestValidateCommandArgs(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
var cfg NodeConfig var cfg NodeConfig
err := serialization.UnmarshalValidateYAML([]byte(tt.yamlCfg), &cfg) err := serialization.UnmarshalValidate([]byte(tt.yamlCfg), &cfg, yaml.Unmarshal)
if tt.wantErr { if tt.wantErr {
require.Error(t, err) require.Error(t, err)
require.ErrorContains(t, err, "input contains invalid characters") require.ErrorContains(t, err, "input contains invalid characters")

View File

@@ -5,6 +5,7 @@ import (
"path" "path"
"strings" "strings"
"github.com/goccy/go-yaml"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/yusing/godoxy/internal/common" "github.com/yusing/godoxy/internal/common"
@@ -43,7 +44,7 @@ func removeXPrefix(m map[string]any) gperr.Error {
} }
func validate(data []byte) (routes route.Routes, err gperr.Error) { func validate(data []byte) (routes route.Routes, err gperr.Error) {
err = serialization.UnmarshalValidateYAMLIntercept(data, &routes, removeXPrefix) err = serialization.UnmarshalValidate(data, &routes, yaml.Unmarshal, removeXPrefix)
return routes, err return routes, err
} }

View File

@@ -3,6 +3,7 @@ package serialization
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"io"
"os" "os"
"reflect" "reflect"
"regexp" "regexp"
@@ -85,6 +86,10 @@ func initPtr(dst reflect.Value) {
} }
} }
// Validate performs struct validation using go-playground/validator tags.
//
// It collects all validation errors and returns them as a single error.
// Field names in errors are prefixed with their namespace (e.g., "User.Email").
func ValidateWithFieldTags(s any) gperr.Error { func ValidateWithFieldTags(s any) gperr.Error {
var errs gperr.Builder var errs gperr.Builder
err := validate.Struct(s) err := validate.Struct(s)
@@ -257,10 +262,11 @@ func initTypeKeyFieldIndexesMap(t reflect.Type) typeInfo {
} }
} }
// MapUnmarshalValidate takes a SerializedObject and a target value, and assigns the values in the SerializedObject to the target value. // MapUnmarshalValidate takes a SerializedObject and a target value,
// MapUnmarshalValidate ignores case differences between the field names in the SerializedObject and the target. // and assigns the values in the SerializedObject to the target value.
//
// It ignores case differences between the field names in the SerializedObject and the target.
// //
// The target value must be a struct or a map[string]any.
// If the target value is a struct , and implements the MapUnmarshaller interface, // If the target value is a struct , and implements the MapUnmarshaller interface,
// the UnmarshalMap method will be called. // the UnmarshalMap method will be called.
// //
@@ -455,6 +461,13 @@ func Convert(src reflect.Value, dst reflect.Value, checkValidateTag bool) gperr.
return ErrUnsupportedConversion.Subjectf("%s to %s", srcT, dstT) return ErrUnsupportedConversion.Subjectf("%s to %s", srcT, dstT)
} }
// ConvertSlice converts a source slice to a destination slice.
//
// - Elements are converted one by one using the Convert function.
// - Validation is performed on each element if checkValidateTag is true.
// - The destination slice is initialized with the source length.
// - On error, the destination slice is truncated to the number of
// successfully converted elements.
func ConvertSlice(src reflect.Value, dst reflect.Value, checkValidateTag bool) gperr.Error { func ConvertSlice(src reflect.Value, dst reflect.Value, checkValidateTag bool) gperr.Error {
if dst.Kind() == reflect.Pointer { if dst.Kind() == reflect.Pointer {
if dst.IsNil() && !dst.CanSet() { if dst.IsNil() && !dst.CanSet() {
@@ -507,6 +520,12 @@ func ConvertSlice(src reflect.Value, dst reflect.Value, checkValidateTag bool) g
return nil return nil
} }
// ConvertString converts a string value to the destination reflect.Value.
// - It handles various types including numeric types, booleans, time.Duration,
// slices (comma-separated or YAML), maps, and structs (YAML).
// - If the destination implements the Parser interface, it is used for conversion.
// - Returns true if conversion was handled (even with error), false if
// conversion is unsupported.
func ConvertString(src string, dst reflect.Value) (convertible bool, convErr gperr.Error) { func ConvertString(src string, dst reflect.Value) (convertible bool, convErr gperr.Error) {
convertible = true convertible = true
dstT := dst.Type() dstT := dst.Type()
@@ -618,48 +637,80 @@ func substituteEnv(data []byte) ([]byte, gperr.Error) {
return data, nil return data, nil
} }
func UnmarshalValidateYAML[T any](data []byte, target *T) gperr.Error { type (
marshalFunc func(src any) ([]byte, error)
unmarshalFunc func(data []byte, target any) error
newDecoderFunc func(r io.Reader) interface {
Decode(v any) error
}
interceptFunc func(m map[string]any) gperr.Error
)
// UnmarshalValidate unmarshals data into a map, applies optional intercept
// functions, and validates the result against the target struct using field tags.
// - Environment variables in the data are substituted using ${VAR} syntax.
// - The unmarshaler function converts data to a map[string]any.
// - Intercept functions can modify or validate the map before unmarshaling.
func UnmarshalValidate[T any](data []byte, target *T, unmarshaler unmarshalFunc, interceptFns ...interceptFunc) gperr.Error {
data, err := substituteEnv(data) data, err := substituteEnv(data)
if err != nil { if err != nil {
return err return err
} }
m := make(map[string]any) m := make(map[string]any)
if err := yaml.Unmarshal(data, &m); err != nil { if err := unmarshaler(data, &m); err != nil {
return gperr.Wrap(err) return gperr.Wrap(err)
} }
for _, intercept := range interceptFns {
if err := intercept(m); err != nil {
return err
}
}
return MapUnmarshalValidate(m, target) return MapUnmarshalValidate(m, target)
} }
func UnmarshalValidateYAMLIntercept[T any](data []byte, target *T, intercept func(m map[string]any) gperr.Error) gperr.Error { // UnmarshalValidateReader reads from an io.Reader, unmarshals to a map,
// - Applies optional intercept functions, and validates against the target struct.
// - Environment variables are substituted during reading using ${VAR} syntax.
// - The newDecoder function creates a decoder for the reader (e.g.,
// json.NewDecoder).
func UnmarshalValidateReader[T any](reader io.Reader, target *T, newDecoder newDecoderFunc, interceptFns ...interceptFunc) gperr.Error {
m := make(map[string]any)
if err := newDecoder(NewSubstituteEnvReader(reader)).Decode(&m); err != nil {
return gperr.Wrap(err)
}
for _, intercept := range interceptFns {
if err := intercept(m); err != nil {
return err
}
}
return MapUnmarshalValidate(m, target)
}
// UnmarshalValidateXSync unmarshals data into an xsync.Map[string, V].
// - Environment variables in the data are substituted using ${VAR} syntax.
// - The unmarshaler function converts data to a map[string]any.
// - Intercept functions can modify or validate the map before unmarshaling.
// - Returns a thread-safe concurrent map with the unmarshaled values.
func UnmarshalValidateXSync[V any](data []byte, unmarshaler unmarshalFunc, interceptFns ...interceptFunc) (*xsync.Map[string, V], gperr.Error) {
data, err := substituteEnv(data) data, err := substituteEnv(data)
if err != nil { if err != nil {
return err return nil, err
} }
m := make(map[string]any) m := make(map[string]any)
if err := yaml.Unmarshal(data, &m); err != nil { if err := unmarshaler(data, &m); err != nil {
return gperr.Wrap(err) return nil, gperr.Wrap(err)
} }
if err := intercept(m); err != nil { for _, intercept := range interceptFns {
return err if err := intercept(m); err != nil {
} return nil, err
return MapUnmarshalValidate(m, target) }
}
func UnmarshalValidateYAMLXSync[V any](data []byte) (_ *xsync.Map[string, V], err gperr.Error) {
data, err = substituteEnv(data)
if err != nil {
return
} }
m := make(map[string]any)
if err = gperr.Wrap(yaml.Unmarshal(data, &m)); err != nil {
return
}
m2 := make(map[string]V, len(m)) m2 := make(map[string]V, len(m))
if err = MapUnmarshalValidate(m, m2); err != nil { if err = MapUnmarshalValidate(m, m2); err != nil {
return return nil, err
} }
ret := xsync.NewMap[string, V](xsync.WithPresize(len(m))) ret := xsync.NewMap[string, V](xsync.WithPresize(len(m)))
for k, v := range m2 { for k, v := range m2 {
@@ -668,26 +719,27 @@ func UnmarshalValidateYAMLXSync[V any](data []byte) (_ *xsync.Map[string, V], er
return ret, nil return ret, nil
} }
func loadSerialized[T any](path string, dst *T, deserialize func(data []byte, dst any) error) error { // SaveFile marshals a value to bytes and writes it to a file.
data, err := os.ReadFile(path) // - The marshaler function converts the value to bytes.
if err != nil { // - The file is written with the specified permissions.
return err func SaveFile[T any](path string, src *T, perm os.FileMode, marshaler marshalFunc) error {
} data, err := marshaler(src)
return deserialize(data, dst)
}
func SaveJSON[T any](path string, src *T, perm os.FileMode) error {
data, err := json.Marshal(src)
if err != nil { if err != nil {
return err return err
} }
return os.WriteFile(path, data, perm) return os.WriteFile(path, data, perm)
} }
func LoadJSONIfExist[T any](path string, dst *T) error { // LoadFileIfExist reads a file and unmarshals its contents to a value.
err := loadSerialized(path, dst, json.Unmarshal) // - The unmarshaler function converts the bytes to a value.
if os.IsNotExist(err) { // - If the file does not exist, nil is returned and dst remains unchanged.
return nil func LoadFileIfExist[T any](path string, dst *T, unmarshaler unmarshalFunc) error {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
} }
return err return unmarshaler(data, dst)
} }

View File

@@ -6,6 +6,7 @@ import (
"strconv" "strconv"
"testing" "testing"
"github.com/goccy/go-yaml"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
expect "github.com/yusing/goutils/testing" expect "github.com/yusing/goutils/testing"
) )
@@ -303,6 +304,6 @@ autocert:
} `yaml:"options"` } `yaml:"options"`
} `yaml:"autocert"` } `yaml:"autocert"`
} }
require.NoError(t, UnmarshalValidateYAML(data, &cfg)) require.NoError(t, UnmarshalValidate(data, &cfg, yaml.Unmarshal))
require.Equal(t, "test", cfg.Autocert.Options.AuthToken) require.Equal(t, "test", cfg.Autocert.Options.AuthToken)
} }

View File

@@ -3,6 +3,7 @@ package types
import ( import (
"testing" "testing"
"github.com/goccy/go-yaml"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/yusing/godoxy/internal/serialization" "github.com/yusing/godoxy/internal/serialization"
) )
@@ -10,14 +11,14 @@ import (
func TestDockerProviderConfigUnmarshalMap(t *testing.T) { func TestDockerProviderConfigUnmarshalMap(t *testing.T) {
t.Run("string", func(t *testing.T) { t.Run("string", func(t *testing.T) {
var cfg map[string]*DockerProviderConfig var cfg map[string]*DockerProviderConfig
err := serialization.UnmarshalValidateYAML([]byte("test: http://localhost:2375"), &cfg) err := serialization.UnmarshalValidate([]byte("test: http://localhost:2375"), &cfg, yaml.Unmarshal)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, &DockerProviderConfig{URL: "http://localhost:2375"}, cfg["test"]) assert.Equal(t, &DockerProviderConfig{URL: "http://localhost:2375"}, cfg["test"])
}) })
t.Run("detailed", func(t *testing.T) { t.Run("detailed", func(t *testing.T) {
var cfg map[string]*DockerProviderConfig var cfg map[string]*DockerProviderConfig
err := serialization.UnmarshalValidateYAML([]byte(` err := serialization.UnmarshalValidate([]byte(`
test: test:
scheme: http scheme: http
host: localhost host: localhost
@@ -25,7 +26,7 @@ test:
tls: tls:
ca_file: /etc/ssl/ca.crt ca_file: /etc/ssl/ca.crt
cert_file: /etc/ssl/cert.crt cert_file: /etc/ssl/cert.crt
key_file: /etc/ssl/key.crt`), &cfg) key_file: /etc/ssl/key.crt`), &cfg, yaml.Unmarshal)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, &DockerProviderConfig{URL: "http://localhost:2375", TLS: &DockerTLSConfig{CAFile: "/etc/ssl/ca.crt", CertFile: "/etc/ssl/cert.crt", KeyFile: "/etc/ssl/key.crt"}}, cfg["test"]) assert.Equal(t, &DockerProviderConfig{URL: "http://localhost:2375", TLS: &DockerTLSConfig{CAFile: "/etc/ssl/ca.crt", CertFile: "/etc/ssl/cert.crt", KeyFile: "/etc/ssl/key.crt"}}, cfg["test"])
}) })
@@ -131,7 +132,7 @@ func TestDockerProviderConfigValidation(t *testing.T) {
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
var cfg map[string]*DockerProviderConfig var cfg map[string]*DockerProviderConfig
err := serialization.UnmarshalValidateYAML([]byte(test.yamlStr), &cfg) err := serialization.UnmarshalValidate([]byte(test.yamlStr), &cfg, yaml.Unmarshal)
if test.wantErr { if test.wantErr {
assert.Error(t, err) assert.Error(t, err)
} else { } else {