diff --git a/config/config.go b/config/config.go index b0cd4c1..cd6031c 100644 --- a/config/config.go +++ b/config/config.go @@ -20,9 +20,12 @@ package config import ( "errors" "fmt" + "reflect" "strconv" "strings" "time" + + "github.com/mitchellh/mapstructure" ) // Config is a map of configuration values. The keys are the configuration @@ -143,3 +146,68 @@ func (c Config) validateParamValue(key string, param Parameter) error { return errors.Join(errs...) } + +// DecodeInto copies configuration values into the target object. +// Under the hood, this function uses github.com/mitchellh/mapstructure, with +// the "mapstructure" tag renamed to "json". To rename a key, use the "json" +// tag. To embed structs, append ",squash" to your tag. For more details and +// docs, see https://pkg.go.dev/github.com/mitchellh/mapstructure. +func (c Config) DecodeInto(target any) error { + dConfig := &mapstructure.DecoderConfig{ + WeaklyTypedInput: true, + Result: &target, + DecodeHook: mapstructure.ComposeDecodeHookFunc( + emptyStringToZeroValueHookFunc(), + mapstructure.StringToTimeDurationHookFunc(), + mapstructure.StringToSliceHookFunc(","), + ), + TagName: "json", + Squash: true, + } + + decoder, err := mapstructure.NewDecoder(dConfig) + if err != nil { + return fmt.Errorf("failed to create decoder: %w", err) + } + err = decoder.Decode(c.breakUp()) + if err != nil { + return fmt.Errorf("failed to decode configuration map into target: %w", err) + } + + return nil +} + +// breakUp breaks up the configuration into a map of maps based on the dot separator. +func (c Config) breakUp() map[string]any { + const sep = "." + + brokenUp := make(map[string]any) + for k, v := range c { + // break up based on dot and put in maps in case target struct is broken up + tokens := strings.Split(k, sep) + remain := k + current := brokenUp + for _, t := range tokens { + current[remain] = v // we don't care if we overwrite a map here, the string has precedence + if _, ok := current[t]; !ok { + current[t] = map[string]any{} + } + var ok bool + current, ok = current[t].(map[string]any) + if !ok { + break // this key is a string, leave it as it is + } + _, remain, _ = strings.Cut(remain, sep) + } + } + return brokenUp +} + +func emptyStringToZeroValueHookFunc() mapstructure.DecodeHookFunc { + return func(f reflect.Type, t reflect.Type, data any) (any, error) { + if f.Kind() != reflect.String || data != "" { + return data, nil + } + return reflect.New(t).Elem().Interface(), nil + } +} diff --git a/config/config_test.go b/config/config_test.go index b4d9ad0..ee08380 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -18,6 +18,7 @@ import ( "errors" "regexp" "testing" + "time" "github.com/matryer/is" ) @@ -344,3 +345,206 @@ func unwrapErrors(err error) []error { } return out } + +func TestParseConfig_Simple_Struct(t *testing.T) { + is := is.New(t) + + type Person struct { + Name string `json:"person_name"` + Age int + Dur time.Duration + } + + input := Config{ + "person_name": "meroxa", + "age": "91", + "dur": "", // empty value should result in zero value + } + want := Person{ + Name: "meroxa", + Age: 91, + } + + var got Person + err := input.DecodeInto(&got) + is.NoErr(err) + is.Equal(want, got) +} + +func TestParseConfig_Embedded_Struct(t *testing.T) { + is := is.New(t) + + type Family struct { + LastName string `json:"last.name"` + } + type Location struct { + City string + } + type Person struct { + Family // last.name + Location // City + F1 Family // F1.last.name + // City + L1 Location `json:",squash"` //nolint:staticcheck // json here is a rename for the mapstructure tag + L2 Location // L2.City + L3 Location `json:"loc3"` // loc3.City + FirstName string `json:"First.Name"` // First.Name + First string // First + } + + input := Config{ + "last.name": "meroxa", + "F1.last.name": "turbine", + "City": "San Francisco", + "L2.City": "Paris", + "loc3.City": "London", + "First.Name": "conduit", + "First": "Mickey", + } + want := Person{ + Family: Family{LastName: "meroxa"}, + F1: Family{LastName: "turbine"}, + Location: Location{City: "San Francisco"}, + L1: Location{City: "San Francisco"}, + L2: Location{City: "Paris"}, + L3: Location{City: "London"}, + FirstName: "conduit", + First: "Mickey", + } + + var got Person + err := input.DecodeInto(&got) + is.NoErr(err) + is.Equal(want, got) +} + +func TestParseConfig_All_Types(t *testing.T) { + is := is.New(t) + type testCfg struct { + MyString string + MyBool1 bool + MyBool2 bool + MyBool3 bool + MyBoolDefault bool + + MyInt int + MyUint uint + MyInt8 int8 + MyUint8 uint8 + MyInt16 int16 + MyUint16 uint16 + MyInt32 int32 + MyUint32 uint32 + MyInt64 int64 + MyUint64 uint64 + + MyByte byte + MyRune rune + + MyFloat32 float32 + MyFloat64 float64 + + MyDuration time.Duration + MyDurationDefault time.Duration + + MySlice []string + MyIntSlice []int + MyFloatSlice []float32 + } + + input := Config{ + "mystring": "string", + "mybool1": "t", + "mybool2": "true", + "mybool3": "1", // 1 is true + "myInt": "-1", + "myuint": "1", + "myint8": "-1", + "myuint8": "1", + "myInt16": "-1", + "myUint16": "1", + "myint32": "-1", + "myuint32": "1", + "myint64": "-1", + "myuint64": "1", + + "mybyte": "99", // 99 fits in one byte + "myrune": "4567", + + "myfloat32": "1.1122334455", + "myfloat64": "1.1122334455", + + "myduration": "1s", + + "myslice": "1,2,3,4", + "myIntSlice": "1,2,3,4", + "myFloatSlice": "1.1,2.2", + } + want := testCfg{ + MyString: "string", + MyBool1: true, + MyBool2: true, + MyBool3: true, + MyBoolDefault: false, // default + MyInt: -1, + MyUint: 0x1, + MyInt8: -1, + MyUint8: 0x1, + MyInt16: -1, + MyUint16: 0x1, + MyInt32: -1, + MyUint32: 0x1, + MyInt64: -1, + MyUint64: 0x1, + MyByte: 0x63, + MyRune: 4567, + MyFloat32: 1.1122334, + MyFloat64: 1.1122334455, + MyDuration: 1000000000, + MyDurationDefault: 0, + MySlice: []string{"1", "2", "3", "4"}, + MyIntSlice: []int{1, 2, 3, 4}, + MyFloatSlice: []float32{1.1, 2.2}, + } + + var result testCfg + err := input.DecodeInto(&result) + is.NoErr(err) + is.Equal(want, result) +} + +func TestBreakUpConfig(t *testing.T) { + is := is.New(t) + + input := Config{ + "foo.bar.baz": "1", + "test": "2", + } + want := map[string]interface{}{ + "foo": map[string]interface{}{ + "bar": map[string]interface{}{ + "baz": "1", + }, + "bar.baz": "1", + }, + "foo.bar.baz": "1", + "test": "2", + } + got := input.breakUp() + is.Equal(want, got) +} + +func TestBreakUpConfig_Conflict_Value(t *testing.T) { + is := is.New(t) + + input := Config{ + "foo": "1", + "foo.bar.baz": "1", // key foo is already taken, will not be broken up + } + want := map[string]interface{}{ + "foo": "1", + "foo.bar.baz": "1", + } + got := input.breakUp() + is.Equal(want, got) +} diff --git a/go.mod b/go.mod index b54c6e5..0eb093f 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/google/go-cmp v0.6.0 github.com/google/uuid v1.6.0 github.com/matryer/is v1.4.1 + github.com/mitchellh/mapstructure v1.5.0 go.uber.org/goleak v1.3.0 golang.org/x/tools v0.18.0 google.golang.org/protobuf v1.32.0 @@ -149,7 +150,6 @@ require ( github.com/mbilski/exhaustivestruct v1.2.0 // indirect github.com/mgechev/revive v1.3.7 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/moby/term v0.5.0 // indirect github.com/moricho/tparallel v0.3.1 // indirect github.com/morikuni/aec v1.0.0 // indirect