From 2e9790451f438ceffdddd082f8204c57cde0c060 Mon Sep 17 00:00:00 2001 From: Brian Picciano Date: Mon, 13 Aug 2018 19:40:41 -0400 Subject: [PATCH] mcfg: implement SourceEnv, and move a bunch of code it shares with SourceCLI to source(_test).go --- mcfg/cli_test.go | 118 ++++-------------------------------- mcfg/env.go | 78 ++++++++++++++++++++++++ mcfg/env_test.go | 58 ++++++++++++++++++ mcfg/source_test.go | 142 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 288 insertions(+), 108 deletions(-) create mode 100644 mcfg/env.go create mode 100644 mcfg/env_test.go create mode 100644 mcfg/source_test.go diff --git a/mcfg/cli_test.go b/mcfg/cli_test.go index 3dd3b27..94422df 100644 --- a/mcfg/cli_test.go +++ b/mcfg/cli_test.go @@ -2,14 +2,11 @@ package mcfg import ( "bytes" - "encoding/json" - "fmt" "strings" . "testing" "time" "github.com/mediocregopher/mediocre-go-lib/mrand" - "github.com/mediocregopher/mediocre-go-lib/mtest/massert" "github.com/mediocregopher/mediocre-go-lib/mtest/mchk" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -46,105 +43,35 @@ func TestSourceCLIHelp(t *T) { func TestSourceCLI(t *T) { type state struct { - cfg *Cfg - availCfgs []*Cfg - + srcCommonState SourceCLI - expPVs []ParamValue } type params struct { - name string - availCfgI int // not technically needed, but makes subsequent steps easier - path []string - isBool bool - nonBoolType string // "int", "str", "duration", "json" - unset bool - nonBoolWEq bool // use equal sign when setting value - nonBoolVal string + srcCommonParams + nonBoolWEq bool // use equal sign when setting value } chk := mchk.Checker{ Init: func() mchk.State { var s state - s.cfg = New() - { - a := s.cfg.Child("a") - b := s.cfg.Child("b") - c := s.cfg.Child("c") - ab := a.Child("b") - bc := b.Child("c") - abc := ab.Child("c") - s.availCfgs = []*Cfg{s.cfg, a, b, c, ab, bc, abc} - } + s.srcCommonState = newSrcCommonState() s.SourceCLI.Args = make([]string, 0, 16) return s }, Next: func(ss mchk.State) mchk.Action { s := ss.(state) var p params - if i := mrand.Intn(8); i == 0 { - p.name = mrand.Hex(1) + "-" + mrand.Hex(8) - } else if i == 1 { - p.name = mrand.Hex(1) + "=" + mrand.Hex(8) - } else { - p.name = mrand.Hex(8) - } - - p.availCfgI = mrand.Intn(len(s.availCfgs)) - thisCfg := s.availCfgs[p.availCfgI] - p.path = thisCfg.Path - - p.isBool = mrand.Intn(2) == 0 - if !p.isBool { - p.nonBoolType = mrand.Element([]string{ - "int", - "str", - "duration", - "json", - }, nil).(string) - } - p.unset = mrand.Intn(10) == 0 - - if p.isBool || p.unset { - return mchk.Action{Params: p} - } - + p.srcCommonParams = s.srcCommonState.next() + // if the param is a bool or unset this won't get used, but w/e p.nonBoolWEq = mrand.Intn(2) == 0 - switch p.nonBoolType { - case "int": - p.nonBoolVal = fmt.Sprint(mrand.Int()) - case "str": - p.nonBoolVal = mrand.Hex(16) - case "duration": - dur := time.Duration(mrand.Intn(86400)) * time.Second - p.nonBoolVal = dur.String() - case "json": - b, _ := json.Marshal(map[string]int{ - mrand.Hex(4): mrand.Int(), - mrand.Hex(4): mrand.Int(), - mrand.Hex(4): mrand.Int(), - }) - p.nonBoolVal = string(b) - } return mchk.Action{Params: p} }, Apply: func(ss mchk.State, a mchk.Action) (mchk.State, error) { s := ss.(state) p := a.Params.(params) - // the param needs to get added to its cfg as a Param - thisCfg := s.availCfgs[p.availCfgI] - cfgP := Param{ - Name: p.name, - IsString: p.nonBoolType == "str" || p.nonBoolType == "duration", - IsBool: p.isBool, - // the cli parser doesn't actually care about the other fields of Param, - // those are only used by Cfg once it has all ParamValues together - } - thisCfg.ParamAdd(cfgP) - - // if the arg is set then add it to the cli args and the expected output pvs + s.srcCommonState = s.srcCommonState.applyCfgAndPV(p.srcCommonParams) if !p.unset { arg := cliKeyPrefix if len(p.path) > 0 { @@ -161,39 +88,14 @@ func TestSourceCLI(t *T) { arg += p.nonBoolVal } s.SourceCLI.Args = append(s.SourceCLI.Args, arg) - - pv := ParamValue{ - Param: cfgP, - Path: p.path, - } - if p.isBool { - pv.Value = json.RawMessage("true") - } else { - switch p.nonBoolType { - case "str", "duration": - pv.Value = json.RawMessage(fmt.Sprintf("%q", p.nonBoolVal)) - case "int", "json": - pv.Value = json.RawMessage(p.nonBoolVal) - default: - panic("shouldn't get here") - } - } - s.expPVs = append(s.expPVs, pv) } - // and finally the state needs to be checked - gotPVs, err := s.SourceCLI.Parse(s.cfg) - if err != nil { - return nil, err - } - return s, massert.All( - massert.Len(gotPVs, len(s.expPVs)), - massert.Subset(s.expPVs, gotPVs), - ).Assert() + err := s.srcCommonState.assert(s.SourceCLI) + return s, err }, } - if err := chk.RunFor(5 * time.Second); err != nil { + if err := chk.RunFor(2 * time.Second); err != nil { t.Fatal(err) } } diff --git a/mcfg/env.go b/mcfg/env.go new file mode 100644 index 0000000..1267ad0 --- /dev/null +++ b/mcfg/env.go @@ -0,0 +1,78 @@ +package mcfg + +import ( + "encoding/json" + "fmt" + "os" + "strings" +) + +// SourceEnv is a Source which will parse configuration from the process +// environment. +// +// Possible Env options are generated by joining a Param's Path and Name with +// underscores and making all characters uppercase, as well as changing all +// dashes to underscores. +// +// cfg := mcfg.New().Child("foo").Child("bar") +// addr := cfg.ParamString("srv-addr", "", "Some address") +// // the Env option to fill addr will be "FOO_BAR_SRV_ADDR" +// +type SourceEnv struct { + Env []string // in the format key=value + + // If set then all expected Env options must be prefixed with this string, + // which will be uppercased and have dashes replaced with underscores like + // all the other parts of the option names. + Prefix string +} + +func (env SourceEnv) expectedName(path []string, name string) string { + out := strings.Join(append(path, name), "_") + if env.Prefix != "" { + out = env.Prefix + "_" + out + } + out = strings.Replace(out, "-", "_", -1) + out = strings.ToUpper(out) + return out +} + +// Parse implements the method for the Source interface +func (env SourceEnv) Parse(cfg *Cfg) ([]ParamValue, error) { + kvs := env.Env + if kvs == nil { + kvs = os.Environ() + } + + pvM := map[string]ParamValue{} + for _, pv := range cfg.allParamValues() { + name := env.expectedName(pv.Path, pv.Name) + pvM[name] = pv + } + + pvs := make([]ParamValue, 0, len(kvs)) + for _, kv := range kvs { + split := strings.SplitN(kv, "=", 2) + if len(split) != 2 { + return nil, fmt.Errorf("malformed environment kv %q", kv) + } + k, v := split[0], split[1] + if pv, ok := pvM[k]; ok { + if pv.IsBool { + if v == "" { + pv.Value = json.RawMessage("false") + } else { + pv.Value = json.RawMessage("true") + } + } else if pv.IsString && (v == "" || v[0] != '"') { + pv.Value = json.RawMessage(`"` + v + `"`) + + } else { + pv.Value = json.RawMessage(v) + } + pvs = append(pvs, pv) + } + } + + return pvs, nil +} diff --git a/mcfg/env_test.go b/mcfg/env_test.go new file mode 100644 index 0000000..30f8593 --- /dev/null +++ b/mcfg/env_test.go @@ -0,0 +1,58 @@ +package mcfg + +import ( + "strings" + . "testing" + "time" + + "github.com/mediocregopher/mediocre-go-lib/mtest/mchk" +) + +func TestSourceEnv(t *T) { + type state struct { + srcCommonState + SourceEnv + } + + type params struct { + srcCommonParams + } + + chk := mchk.Checker{ + Init: func() mchk.State { + var s state + s.srcCommonState = newSrcCommonState() + s.SourceEnv.Env = make([]string, 0, 16) + return s + }, + Next: func(ss mchk.State) mchk.Action { + s := ss.(state) + var p params + p.srcCommonParams = s.srcCommonState.next() + return mchk.Action{Params: p} + }, + Apply: func(ss mchk.State, a mchk.Action) (mchk.State, error) { + s := ss.(state) + p := a.Params.(params) + s.srcCommonState = s.srcCommonState.applyCfgAndPV(p.srcCommonParams) + if !p.unset { + kv := strings.Join(append(p.path, p.name), "_") + kv = strings.Replace(kv, "-", "_", -1) + kv = strings.ToUpper(kv) + kv += "=" + if p.isBool { + kv += "1" + } else { + kv += p.nonBoolVal + } + s.SourceEnv.Env = append(s.SourceEnv.Env, kv) + } + err := s.srcCommonState.assert(s.SourceEnv) + return s, err + }, + } + + if err := chk.RunFor(2 * time.Second); err != nil { + t.Fatal(err) + } +} diff --git a/mcfg/source_test.go b/mcfg/source_test.go new file mode 100644 index 0000000..9aa9b79 --- /dev/null +++ b/mcfg/source_test.go @@ -0,0 +1,142 @@ +package mcfg + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/mediocregopher/mediocre-go-lib/mrand" + "github.com/mediocregopher/mediocre-go-lib/mtest/massert" +) + +// The tests for the different Sources use mchk as their primary method of +// checking. They end up sharing a lot of the same functionality, so in here is +// all the code they share + +type srcCommonState struct { + cfg *Cfg + availCfgs []*Cfg + expPVs []ParamValue + + // each specific test should wrap this to add the Source itself +} + +func newSrcCommonState() srcCommonState { + var scs srcCommonState + scs.cfg = New() + { + a := scs.cfg.Child("a") + b := scs.cfg.Child("b") + c := scs.cfg.Child("c") + ab := a.Child("b") + bc := b.Child("c") + abc := ab.Child("c") + scs.availCfgs = []*Cfg{scs.cfg, a, b, c, ab, bc, abc} + } + return scs +} + +type srcCommonParams struct { + name string + availCfgI int // not technically needed, but finding the cfg easier + path []string + isBool bool + nonBoolType string // "int", "str", "duration", "json" + unset bool + nonBoolVal string +} + +func (scs srcCommonState) next() srcCommonParams { + var p srcCommonParams + if i := mrand.Intn(8); i == 0 { + p.name = mrand.Hex(1) + "-" + mrand.Hex(8) + } else { + p.name = mrand.Hex(8) + } + + p.availCfgI = mrand.Intn(len(scs.availCfgs)) + thisCfg := scs.availCfgs[p.availCfgI] + p.path = thisCfg.Path + + p.isBool = mrand.Intn(2) == 0 + if !p.isBool { + p.nonBoolType = mrand.Element([]string{ + "int", + "str", + "duration", + "json", + }, nil).(string) + } + p.unset = mrand.Intn(10) == 0 + + if p.isBool || p.unset { + return p + } + + switch p.nonBoolType { + case "int": + p.nonBoolVal = fmt.Sprint(mrand.Int()) + case "str": + p.nonBoolVal = mrand.Hex(16) + case "duration": + dur := time.Duration(mrand.Intn(86400)) * time.Second + p.nonBoolVal = dur.String() + case "json": + b, _ := json.Marshal(map[string]int{ + mrand.Hex(4): mrand.Int(), + mrand.Hex(4): mrand.Int(), + mrand.Hex(4): mrand.Int(), + }) + p.nonBoolVal = string(b) + } + return p +} + +// adds the new cfg param to the cfg, and if the param is expected to be set in +// the Source adds it to the expected ParamValues as well +func (scs srcCommonState) applyCfgAndPV(p srcCommonParams) srcCommonState { + thisCfg := scs.availCfgs[p.availCfgI] + cfgP := Param{ + Name: p.name, + IsString: p.nonBoolType == "str" || p.nonBoolType == "duration", + IsBool: p.isBool, + // the Sources don't actually care about the other fields of Param, + // those are only used by Cfg once it has all ParamValues together + } + thisCfg.ParamAdd(cfgP) + + if !p.unset { + pv := ParamValue{ + Param: cfgP, + Path: p.path, + } + if p.isBool { + pv.Value = json.RawMessage("true") + } else { + switch p.nonBoolType { + case "str", "duration": + pv.Value = json.RawMessage(fmt.Sprintf("%q", p.nonBoolVal)) + case "int", "json": + pv.Value = json.RawMessage(p.nonBoolVal) + default: + panic("shouldn't get here") + } + } + scs.expPVs = append(scs.expPVs, pv) + } + + return scs +} + +// given a Source asserts that it's Parse method returns the expected +// ParamValues +func (scs srcCommonState) assert(s Source) error { + gotPVs, err := s.Parse(scs.cfg) + if err != nil { + return err + } + return massert.All( + massert.Len(gotPVs, len(scs.expPVs)), + massert.Subset(scs.expPVs, gotPVs), + ).Assert() +}