Skip to content

Commit

Permalink
add config utility to decode into object
Browse files Browse the repository at this point in the history
  • Loading branch information
lovromazgon committed Feb 16, 2024
1 parent 366ba13 commit cdd09cc
Show file tree
Hide file tree
Showing 3 changed files with 273 additions and 1 deletion.
68 changes: 68 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
204 changes: 204 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"errors"
"regexp"
"testing"
"time"

"github.com/matryer/is"
)
Expand Down Expand Up @@ -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)
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit cdd09cc

Please sign in to comment.