mcfg: implement SourceEnv, and move a bunch of code it shares with SourceCLI to source(_test).go

This commit is contained in:
Brian Picciano 2018-08-13 19:40:41 -04:00
parent 526e35cf3f
commit 2e9790451f
4 changed files with 288 additions and 108 deletions

View File

@ -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
srcCommonParams
nonBoolWEq bool // use equal sign when setting value
nonBoolVal string
}
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)
}
}

78
mcfg/env.go Normal file
View File

@ -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
}

58
mcfg/env_test.go Normal file
View File

@ -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)
}
}

142
mcfg/source_test.go Normal file
View File

@ -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()
}