diff --git a/.travis.yml b/.travis.yml index 2bb6145..6623913 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,6 +7,7 @@ go: - 1.8 - 1.9 - "1.10" + - "1.11" script: - make setup && make test diff --git a/Makefile b/Makefile index 4f43714..f11f483 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,9 @@ setup: ln -s ../../.. src/$(PROJECT) test: - $(GOCMD) test $(PROJECT) -cover + $(GOCMD) version + $(GOCMD) env + $(GOCMD) test -v $(PROJECT) example: $(GOCMD) install $(PROJECT)/example diff --git a/README.md b/README.md index 1d3b117..806d7fa 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,16 @@
-Goconfig is an extremely simple configuration library for your Go programs. +Goconfig is an extremely simple and powerful configuration library for your Go +programs that read values from environment vars, command line arguments and +configuration file in JSON. + Make your configuration flags compact and easy to read. Arguments are parsed from command line with the standard `flag` library. @@ -100,11 +105,10 @@ Mainly almost all types from `flag` library are supported: * uint64 * uint * struct (hyerarchical keys) +* array (any type) For the moment `duration` type is not supported. -Type `slice` or `array` is also being considered. - # Builtin flags @@ -132,6 +136,7 @@ configuration above, this is a sample config.json file: Configuration precedence is as follows (higher to lower): * Arg command line * Json config file +* Environment variable * Default value @@ -147,8 +152,6 @@ or email me for new features, issues or whatever. This command will pass all tests. -No tests are expected for the moment. - ```sh make ``` diff --git a/assertions_test.go b/assertions_test.go new file mode 100644 index 0000000..28ca1da --- /dev/null +++ b/assertions_test.go @@ -0,0 +1,74 @@ +package goconfig + +import ( + "reflect" + "runtime/debug" + "testing" +) + +// AssertNil checks whether the obtained field is equal to nil, +// failing in other case. +func AssertNil(t *testing.T, obtained interface{}) { + + if nil == obtained { + return + } + + if reflect.ValueOf(obtained).IsNil() { + return + } + + line := GetStackLine(2) + t.Errorf("\nExpected: nil \nObtained:%#v\nat %s\n", obtained, line) +} + +// AssertNotNil checks whether the obtained field is distinct to nil, +// failing in other case. +func AssertNotNil(t *testing.T, obtained interface{}) { + + line := GetStackLine(2) + if nil == obtained { + t.Errorf("\nExpected: not nil \nObtained:%#v\nat %s\n", obtained, line) + return + } + + if reflect.ValueOf(obtained).IsNil() { + t.Errorf("\nExpected: not nil \nObtained:%#v\nat %s\n", obtained, line) + return + } + +} + +// AssertEqual checks whether the obtained and expected fields are equal +// failing in other case. +func AssertEqual(t *testing.T, obtained, expected interface{}) bool { + if reflect.DeepEqual(expected, obtained) { + return true + } + + line := GetStackLine(2) + t.Errorf("\nExpected: %#v\nObtained: %#v\nat %s\n", expected, obtained, line) + + return false +} + +// GetStackLine accesses the stack trace to get some lines +// so they can be showed by the tests in case of error. +func GetStackLine(linesToSkip int) string { + + stack := debug.Stack() + lines := make([]string, 0) + index := 0 + for i := 0; i < len(stack); i++ { + if stack[i] == []byte("\n")[0] { + lines = append(lines, string(stack[index:i-1])) + index = i + 1 + } + } + + if linesToSkip >= len(lines) { + return "" + } + + return lines[linesToSkip] +} diff --git a/fill_args.go b/fill_args.go index 5def7b0..8d3e61f 100644 --- a/fill_args.go +++ b/fill_args.go @@ -1,16 +1,24 @@ package goconfig import ( + "bytes" + "encoding/json" + "errors" "flag" + "fmt" "os" "reflect" "strings" - "fmt" ) var values = map[string]interface{}{} -func FillArgs(c interface{}, args []string) { +type postFillArgs struct { + item + Raw *string +} + +func FillArgs(c interface{}, args []string) error { var f = flag.NewFlagSet(os.Args[0], flag.ContinueOnError) f.Usage = func() {} f.SetOutput(os.Stdout) @@ -18,6 +26,8 @@ func FillArgs(c interface{}, args []string) { // Default config flag f.String("config", "", "Configuration JSON file") + post := []postFillArgs{} + traverse(c, func(i item) { name_path := strings.ToLower(strings.Join(i.Path, ".")) @@ -43,7 +53,16 @@ func FillArgs(c interface{}, args []string) { f.UintVar(i.Ptr.(*uint), name_path, i.Value.Interface().(uint), i.Usage) } else if reflect.Slice == i.Kind { - panic("Slice is not supported by goconfig at this moment.") + + b, _ := json.Marshal(i.Value.Interface()) + + value := "" + f.StringVar(&value, name_path, string(b), i.Usage) + + post = append(post, postFillArgs{ + Raw: &value, + item: i, + }) } else { panic("Kind `" + i.Kind.String() + @@ -53,9 +72,23 @@ func FillArgs(c interface{}, args []string) { }) if err := f.Parse(args); err != nil && err == flag.ErrHelp { - f.SetOutput(os.Stderr) - fmt.Fprint(os.Stderr, "Usage of goconfig:\n\n") + m := bytes.NewBufferString("Usage of goconfig:\n\n") + f.SetOutput(m) f.PrintDefaults() - os.Exit(1) + return errors.New(m.String()) + } + + // Postprocess flags: unsupported flags needs to be declared as string + // and parsed later. Here is the place. + for _, p := range post { + err := json.Unmarshal([]byte(*p.Raw), p.Ptr) + if err != nil { + return errors.New(fmt.Sprintf( + "'%s' should be a JSON array: %s", + p.FieldName, err.Error(), + )) + } } + + return nil } diff --git a/fill_args_test.go b/fill_args_test.go index 6e97297..ab2da13 100644 --- a/fill_args_test.go +++ b/fill_args_test.go @@ -1,6 +1,8 @@ package goconfig -import "testing" +import ( + "testing" +) func TestFillArgs(t *testing.T) { c := struct { @@ -29,7 +31,8 @@ func TestFillArgs(t *testing.T) { "-mystruct.myitem", "nested", } - FillArgs(&c, args) + err := FillArgs(&c, args) + AssertNil(t, err) if c.MyBoolTrue != true { t.Error("MyBoolTrue should be true") @@ -68,3 +71,152 @@ func TestFillArgs(t *testing.T) { } } + +func TestFillArgsWithArrayDefinedUndefined(t *testing.T) { + + c := struct { + Defined []string + Undefined []string + }{ + Defined: []string{"default", "values"}, + } + + args := []string{ + `-defined=["one","two","three"]`, + `-undefined=["a", "b", "c"]`, + } + + err := FillArgs(&c, args) + AssertNil(t, err) + + AssertEqual(t, c.Defined, []string{"one", "two", "three"}) + AssertEqual(t, c.Undefined, []string{"a", "b", "c"}) + +} + +func TestFillArgsWithArrayPointers(t *testing.T) { + + c := struct { + Pointers []*string + }{} + + args := []string{ + `-pointers=["one","two","three"]`, + } + + err := FillArgs(&c, args) + AssertNil(t, err) + + one := "one" + two := "two" + three := "three" + + AssertEqual(t, c.Pointers, []*string{&one, &two, &three}) + +} + +func TestFillArgsWithArrayScalars(t *testing.T) { + + c := struct { + // Bool + MyBool []bool + + // String + MyString []string + + // Numbers + MyFloat64 []float64 + MyInt64 []int64 + MyInt []int + MyUint64 []uint64 + MyUint []uint + }{} + + args := []string{ + // Bool + "-mybool", "[true, false, true]", + + // String + "-mystring", `["one", "two", "three"]`, + + // Numbers + "-myfloat64", "[1.23, 1.24]", + "-myint64", "[123, 124]", + "-myint", "[8888, 9999]", + "-myuint64", "[64, 65]", + "-myuint", "[4444, 5555]", + } + + err := FillArgs(&c, args) + AssertNil(t, err) + + // Bool + AssertEqual(t, c.MyBool, []bool{true, false, true}) + + // String + AssertEqual(t, c.MyString, []string{"one", "two", "three"}) + + // Numbers + AssertEqual(t, c.MyFloat64, []float64{1.23, 1.24}) + AssertEqual(t, c.MyInt64, []int64{123, 124}) + AssertEqual(t, c.MyInt, []int{8888, 9999}) + AssertEqual(t, c.MyUint64, []uint64{64, 65}) + AssertEqual(t, c.MyUint, []uint{4444, 5555}) + +} + +func TestFillArgsWithArrayStructs(t *testing.T) { + + type mystruct struct { + Name string + Age int + } + + c := struct { + MyStruct []mystruct + }{} + + args := []string{ + "-mystruct", `[{"name":"Fulanez", "age": 33}, {"name":"Menganez", "age": 22}]`, + } + + err := FillArgs(&c, args) + AssertNil(t, err) + + AssertEqual(t, c.MyStruct, []mystruct{ + {Name: "Fulanez", Age: 33}, + {Name: "Menganez", Age: 22}, + }) + +} + +func TestFillArgsWithArrayMalformed(t *testing.T) { + + c := struct { + MyArray []string + }{} + + args := []string{ + "-myarray", `[1,2,3]`, + } + + err := FillArgs(&c, args) + AssertNotNil(t, err) + + AssertEqual(t, err.Error(), "'MyArray' should be a JSON "+ + "array: json: cannot unmarshal number into Go value of type string") + +} + +func TestFillArgsParseFail(t *testing.T) { + + c := struct{}{} + + args := []string{ + "-help", + } + + err := FillArgs(&c, args) + AssertNotNil(t, err) + +} diff --git a/fill_environments.go b/fill_environments.go index 4e6b897..2578f21 100644 --- a/fill_environments.go +++ b/fill_environments.go @@ -1,13 +1,17 @@ package goconfig import ( + "encoding/json" + "errors" + "fmt" "os" "reflect" "strconv" "strings" ) -func FillEnvironments(c interface{}) { +func FillEnvironments(c interface{}) (err error) { + traverse(c, func(i item) { env := strings.ToUpper(strings.Join(i.Path, "_")) value := os.Getenv(env) @@ -69,7 +73,18 @@ func FillEnvironments(c interface{}) { set(i.Ptr, &w) } + } else if reflect.Slice == i.Kind { + jsonErr := json.Unmarshal([]byte(value), i.Ptr) + if jsonErr != nil { + err = errors.New(fmt.Sprintf( + "'%s' should be a JSON array: %s", + env, jsonErr.Error(), + )) + } + } }) + + return } diff --git a/fill_environments_test.go b/fill_environments_test.go index 5daea6b..9a528dc 100644 --- a/fill_environments_test.go +++ b/fill_environments_test.go @@ -38,7 +38,8 @@ func TestFillEnvironments(t *testing.T) { os.Setenv("MYSTRUCT_MYITEM", "nested") os.Setenv("MYEMPTY", "") - FillEnvironments(&c) + err := FillEnvironments(&c) + AssertNil(t, err) if c.MyBoolTrue != true { t.Error("MyBoolTrue should be true") @@ -93,3 +94,36 @@ func TestFillEnvironments(t *testing.T) { } } + +func TestFillEnvironmentsWithArray(t *testing.T) { + + c := struct { + MyStringArray []string + }{ + []string{"four", "five", "six"}, + } + + os.Setenv("MYSTRINGARRAY", `["one", "two", "three"]`) + + err := FillEnvironments(&c) + AssertNil(t, err) + + AssertEqual(t, c.MyStringArray, []string{"one", "two", "three"}) + +} + +func TestFillEnvironmentsWithArrayMalformed(t *testing.T) { + + c := struct { + MyStringArray []string + }{} + + os.Setenv("MYSTRINGARRAY", `}`) + + err := FillEnvironments(&c) + AssertNotNil(t, err) + + AssertEqual(t, err.Error(), "'MYSTRINGARRAY' should be a JSON"+ + " array: invalid character '}' looking for beginning of value") + +} diff --git a/fill_json.go b/fill_json.go index cbe784b..602667c 100644 --- a/fill_json.go +++ b/fill_json.go @@ -2,26 +2,25 @@ package goconfig import ( "encoding/json" - "fmt" + "errors" "io/ioutil" - "os" ) -func FillJson(c interface{}, filename string) { +func FillJson(c interface{}, filename string) error { if "" == filename { - return + return nil } data, err := ioutil.ReadFile(filename) if nil != err { - fmt.Println("Unable to read config file `" + filename + "`!") - os.Exit(1) + return err } err = json.Unmarshal(data, &c) if nil != err { - fmt.Println("Config file should be a valid JSON") - os.Exit(1) + return errors.New("Bad json file: " + err.Error()) } + + return nil } diff --git a/fill_json_test.go b/fill_json_test.go index 5fbc0bd..ae1efcc 100644 --- a/fill_json_test.go +++ b/fill_json_test.go @@ -1,6 +1,8 @@ package goconfig -import "testing" +import ( + "testing" +) func TestFillJson(t *testing.T) { @@ -25,3 +27,12 @@ func TestFillJson(t *testing.T) { FillJson(c, "") } + +func TestFillJson_UnexistingFilename(t *testing.T) { + + c := struct{}{} + + err := FillJson(&c, "/") + AssertNotNil(t, err) + +} diff --git a/goconfig.go b/goconfig.go index dce9b67..1dafe9f 100644 --- a/goconfig.go +++ b/goconfig.go @@ -1,6 +1,7 @@ package goconfig import ( + "errors" "flag" "io/ioutil" "os" @@ -8,18 +9,34 @@ import ( func Read(c interface{}) { + if err := readWithError(c); err != nil { + os.Stderr.WriteString(err.Error()) + os.Exit(1) + } + +} + +func readWithError(c interface{}) error { + f := flag.NewFlagSet(os.Args[0], flag.ContinueOnError) f.SetOutput(ioutil.Discard) filename := f.String("config", "", "-usage-") f.Parse(os.Args[1:]) // Read from file JSON - FillJson(c, *filename) + if err := FillJson(c, *filename); err != nil { + return errors.New("Config file error: " + err.Error()) + } // Overwrite configuration with environment vars: - FillEnvironments(c) + if err := FillEnvironments(c); err != nil { + return errors.New("Config env error: " + err.Error()) + } // Overwrite configuration with command line args: - FillArgs(c, os.Args[1:]) + if err := FillArgs(c, os.Args[1:]); err != nil { + return errors.New("Config arg error: " + err.Error()) + } + return nil } diff --git a/goconfig_test.go b/goconfig_test.go new file mode 100644 index 0000000..17d3890 --- /dev/null +++ b/goconfig_test.go @@ -0,0 +1,80 @@ +package goconfig + +import ( + "os" + "testing" +) + +func TestRead(t *testing.T) { + + c := struct { + Value string + }{} + + os.Setenv("VALUE", "env") + + Read(&c) + + AssertEqual(t, c.Value, "env") + +} + +func TestReadWithError(t *testing.T) { + + c := struct { + Value string + }{} + + os.Setenv("VALUE", "env") + + err := readWithError(&c) + AssertNil(t, err) + + AssertEqual(t, c.Value, "env") +} + +func TestReadWithError_MalformedEnv(t *testing.T) { + + c := struct { + Value []string + }{} + + os.Setenv("VALUE", "}") + + err := readWithError(&c) + AssertNotNil(t, err) + + AssertEqual(t, err.Error(), "Config env error: "+ + "'VALUE' should be a JSON array: invalid character '}' looking for "+ + "beginning of value") +} + +func TestReadWithError_MalformedArg(t *testing.T) { + + c := struct { + Value []string + }{} + + os.Unsetenv("VALUE") + os.Args = []string{"cmd", "-value", "}"} + + err := readWithError(&c) + AssertNotNil(t, err) + + AssertEqual(t, err.Error(), "Config arg error: "+ + "'Value' should be a JSON array: invalid character '}' looking for "+ + "beginning of value") +} + +func TestReadWithError_FileError(t *testing.T) { + + c := struct{}{} + + os.Args = []string{"cmd", "-config", "/"} + + err := readWithError(&c) + AssertNotNil(t, err) + + AssertEqual(t, err.Error(), "Config file error: "+ + "read /: is a directory") +} diff --git a/traverse.go b/traverse.go index 0478cfe..c1b4e8c 100644 --- a/traverse.go +++ b/traverse.go @@ -47,7 +47,17 @@ func traverse_recursive(c interface{}, f callback, p []string) { traverse_recursive(ptr, f, pr) } else if reflect.Slice == kind { - panic("Slice is not supported by goconfig at this moment.") + //panic("Slice is not supported by goconfig at this moment.") + f(item{ + FieldName: name, + Usage: usage, + Ptr: ptr, + Kind: kind, + Path: pr, + Value: value, + }) + + traverse_array(ptr, f, pr) } else { f(item{ FieldName: name, @@ -67,3 +77,7 @@ func traverse_recursive(c interface{}, f callback, p []string) { } } + +func traverse_array(c interface{}, f callback, p []string) { + +}