From 6a70c4cc923c7359bacfa0500dc234d62e0ca986 Mon Sep 17 00:00:00 2001 From: John Weldon Date: Sat, 2 Jul 2016 12:35:48 -0700 Subject: [PATCH] Add JSON InputSource to altsrc package - Implement NewJSONSource* functions for returning an InputSource from various JSON data sources. - Copy and modify YAML tests for the JSON InputSource Changes: * Reverted the method calls and structs to match the v1 interface --- README.md | 6 +- altsrc/json_command_test.go | 324 ++++++++++++++++++++++++++++++++++ altsrc/json_source_context.go | 208 ++++++++++++++++++++++ 3 files changed, 535 insertions(+), 3 deletions(-) create mode 100644 altsrc/json_command_test.go create mode 100644 altsrc/json_source_context.go diff --git a/README.md b/README.md index 2bbbd8e..0a442a9 100644 --- a/README.md +++ b/README.md @@ -615,9 +615,9 @@ the yaml input source for any flags that are defined on that command. As a note the "load" flag used would also have to be defined on the command flags in order for this code snipped to work. -Currently only the aboved specified formats are supported but developers can -add support for other input sources by implementing the -altsrc.InputSourceContext for their given sources. +Currently only YAML and JSON files are supported but developers can add support +for other input sources by implementing the altsrc.InputSourceContext for their +given sources. Here is a more complete sample of a command using YAML support: diff --git a/altsrc/json_command_test.go b/altsrc/json_command_test.go new file mode 100644 index 0000000..1f9af36 --- /dev/null +++ b/altsrc/json_command_test.go @@ -0,0 +1,324 @@ +package altsrc + +import ( + "flag" + "io/ioutil" + "os" + "testing" + + "gopkg.in/urfave/cli.v1" +) + +const ( + fileName = "current.json" + simpleJSON = `{"test": 15}` + nestedJSON = `{"top": {"test": 15}}` +) + +func TestCommandJSONFileTest(t *testing.T) { + cleanup := writeTempFile(t, fileName, simpleJSON) + defer cleanup() + + app := cli.NewApp() + set := flag.NewFlagSet("test", 0) + test := []string{"test-cmd", "--load", fileName} + set.Parse(test) + + c := cli.NewContext(app, set, nil) + + command := &cli.Command{ + Name: "test-cmd", + Aliases: []string{"tc"}, + Usage: "this is for testing", + Description: "testing", + Action: func(c *cli.Context) error { + val := c.Int("test") + expect(t, val, 15) + return nil + }, + Flags: []cli.Flag{ + NewIntFlag(cli.IntFlag{Name: "test"}), + &cli.StringFlag{Name: "load"}}, + } + command.Before = InitInputSourceWithContext(command.Flags, NewJSONSourceFromFlagFunc("load")) + err := command.Run(c) + + expect(t, err, nil) +} + +func TestCommandJSONFileTestGlobalEnvVarWins(t *testing.T) { + cleanup := writeTempFile(t, fileName, simpleJSON) + defer cleanup() + + app := cli.NewApp() + set := flag.NewFlagSet("test", 0) + os.Setenv("THE_TEST", "10") + defer os.Setenv("THE_TEST", "") + + test := []string{"test-cmd", "--load", fileName} + set.Parse(test) + + c := cli.NewContext(app, set, nil) + + command := &cli.Command{ + Name: "test-cmd", + Aliases: []string{"tc"}, + Usage: "this is for testing", + Description: "testing", + Action: func(c *cli.Context) error { + val := c.Int("test") + expect(t, val, 10) + return nil + }, + Flags: []cli.Flag{ + NewIntFlag(cli.IntFlag{Name: "test", EnvVar: "THE_TEST"}), + &cli.StringFlag{Name: "load"}}, + } + command.Before = InitInputSourceWithContext(command.Flags, NewJSONSourceFromFlagFunc("load")) + + err := command.Run(c) + + expect(t, err, nil) +} + +func TestCommandJSONFileTestGlobalEnvVarWinsNested(t *testing.T) { + cleanup := writeTempFile(t, fileName, nestedJSON) + defer cleanup() + + app := cli.NewApp() + set := flag.NewFlagSet("test", 0) + os.Setenv("THE_TEST", "10") + defer os.Setenv("THE_TEST", "") + + test := []string{"test-cmd", "--load", fileName} + set.Parse(test) + + c := cli.NewContext(app, set, nil) + + command := &cli.Command{ + Name: "test-cmd", + Aliases: []string{"tc"}, + Usage: "this is for testing", + Description: "testing", + Action: func(c *cli.Context) error { + val := c.Int("top.test") + expect(t, val, 10) + return nil + }, + Flags: []cli.Flag{ + NewIntFlag(cli.IntFlag{Name: "top.test", EnvVar: "THE_TEST"}), + &cli.StringFlag{Name: "load"}}, + } + command.Before = InitInputSourceWithContext(command.Flags, NewJSONSourceFromFlagFunc("load")) + + err := command.Run(c) + + expect(t, err, nil) +} + +func TestCommandJSONFileTestSpecifiedFlagWins(t *testing.T) { + cleanup := writeTempFile(t, fileName, simpleJSON) + defer cleanup() + + app := cli.NewApp() + set := flag.NewFlagSet("test", 0) + test := []string{"test-cmd", "--load", fileName, "--test", "7"} + set.Parse(test) + + c := cli.NewContext(app, set, nil) + + command := &cli.Command{ + Name: "test-cmd", + Aliases: []string{"tc"}, + Usage: "this is for testing", + Description: "testing", + Action: func(c *cli.Context) error { + val := c.Int("test") + expect(t, val, 7) + return nil + }, + Flags: []cli.Flag{ + NewIntFlag(cli.IntFlag{Name: "test"}), + &cli.StringFlag{Name: "load"}}, + } + command.Before = InitInputSourceWithContext(command.Flags, NewJSONSourceFromFlagFunc("load")) + + err := command.Run(c) + + expect(t, err, nil) +} + +func TestCommandJSONFileTestSpecifiedFlagWinsNested(t *testing.T) { + cleanup := writeTempFile(t, fileName, nestedJSON) + defer cleanup() + + app := cli.NewApp() + set := flag.NewFlagSet("test", 0) + test := []string{"test-cmd", "--load", fileName, "--top.test", "7"} + set.Parse(test) + + c := cli.NewContext(app, set, nil) + + command := &cli.Command{ + Name: "test-cmd", + Aliases: []string{"tc"}, + Usage: "this is for testing", + Description: "testing", + Action: func(c *cli.Context) error { + val := c.Int("top.test") + expect(t, val, 7) + return nil + }, + Flags: []cli.Flag{ + NewIntFlag(cli.IntFlag{Name: "top.test"}), + &cli.StringFlag{Name: "load"}}, + } + command.Before = InitInputSourceWithContext(command.Flags, NewJSONSourceFromFlagFunc("load")) + + err := command.Run(c) + + expect(t, err, nil) +} + +func TestCommandJSONFileTestDefaultValueFileWins(t *testing.T) { + cleanup := writeTempFile(t, fileName, simpleJSON) + defer cleanup() + + app := cli.NewApp() + set := flag.NewFlagSet("test", 0) + test := []string{"test-cmd", "--load", fileName} + set.Parse(test) + + c := cli.NewContext(app, set, nil) + + command := &cli.Command{ + Name: "test-cmd", + Aliases: []string{"tc"}, + Usage: "this is for testing", + Description: "testing", + Action: func(c *cli.Context) error { + val := c.Int("test") + expect(t, val, 15) + return nil + }, + Flags: []cli.Flag{ + NewIntFlag(cli.IntFlag{Name: "test", Value: 7}), + &cli.StringFlag{Name: "load"}}, + } + command.Before = InitInputSourceWithContext(command.Flags, NewJSONSourceFromFlagFunc("load")) + + err := command.Run(c) + + expect(t, err, nil) +} + +func TestCommandJSONFileTestDefaultValueFileWinsNested(t *testing.T) { + cleanup := writeTempFile(t, fileName, nestedJSON) + defer cleanup() + + app := cli.NewApp() + set := flag.NewFlagSet("test", 0) + test := []string{"test-cmd", "--load", fileName} + set.Parse(test) + + c := cli.NewContext(app, set, nil) + + command := &cli.Command{ + Name: "test-cmd", + Aliases: []string{"tc"}, + Usage: "this is for testing", + Description: "testing", + Action: func(c *cli.Context) error { + val := c.Int("top.test") + expect(t, val, 15) + return nil + }, + Flags: []cli.Flag{ + NewIntFlag(cli.IntFlag{Name: "top.test", Value: 7}), + &cli.StringFlag{Name: "load"}}, + } + command.Before = InitInputSourceWithContext(command.Flags, NewJSONSourceFromFlagFunc("load")) + + err := command.Run(c) + + expect(t, err, nil) +} + +func TestCommandJSONFileFlagHasDefaultGlobalEnvJSONSetGlobalEnvWins(t *testing.T) { + cleanup := writeTempFile(t, fileName, simpleJSON) + defer cleanup() + + app := cli.NewApp() + set := flag.NewFlagSet("test", 0) + os.Setenv("THE_TEST", "11") + defer os.Setenv("THE_TEST", "") + + test := []string{"test-cmd", "--load", fileName} + set.Parse(test) + + c := cli.NewContext(app, set, nil) + + command := &cli.Command{ + Name: "test-cmd", + Aliases: []string{"tc"}, + Usage: "this is for testing", + Description: "testing", + Action: func(c *cli.Context) error { + val := c.Int("test") + expect(t, val, 11) + return nil + }, + Flags: []cli.Flag{ + NewIntFlag(cli.IntFlag{Name: "test", Value: 7, EnvVar: "THE_TEST"}), + &cli.StringFlag{Name: "load"}}, + } + command.Before = InitInputSourceWithContext(command.Flags, NewJSONSourceFromFlagFunc("load")) + err := command.Run(c) + + expect(t, err, nil) +} + +func TestCommandJSONFileFlagHasDefaultGlobalEnvJSONSetGlobalEnvWinsNested(t *testing.T) { + cleanup := writeTempFile(t, fileName, nestedJSON) + defer cleanup() + + app := cli.NewApp() + set := flag.NewFlagSet("test", 0) + os.Setenv("THE_TEST", "11") + defer os.Setenv("THE_TEST", "") + + test := []string{"test-cmd", "--load", fileName} + set.Parse(test) + + c := cli.NewContext(app, set, nil) + + command := &cli.Command{ + Name: "test-cmd", + Aliases: []string{"tc"}, + Usage: "this is for testing", + Description: "testing", + Action: func(c *cli.Context) error { + val := c.Int("top.test") + expect(t, val, 11) + return nil + }, + Flags: []cli.Flag{ + NewIntFlag(cli.IntFlag{Name: "top.test", Value: 7, EnvVar: "THE_TEST"}), + &cli.StringFlag{Name: "load"}}, + } + command.Before = InitInputSourceWithContext(command.Flags, NewJSONSourceFromFlagFunc("load")) + err := command.Run(c) + + expect(t, err, nil) +} + +func writeTempFile(t *testing.T, name string, content string) func() { + if err := ioutil.WriteFile(name, []byte(content), 0666); err != nil { + t.Fatalf("cannot write %q: %v", name, err) + } + return func() { + if err := os.Remove(name); err != nil { + t.Errorf("cannot remove %q: %v", name, err) + } + } +} diff --git a/altsrc/json_source_context.go b/altsrc/json_source_context.go new file mode 100644 index 0000000..47ce82c --- /dev/null +++ b/altsrc/json_source_context.go @@ -0,0 +1,208 @@ +package altsrc + +import ( + "encoding/json" + "fmt" + "io" + "io/ioutil" + "strings" + "time" + + "gopkg.in/urfave/cli.v1" +) + +// NewJSONSourceFromFlagFunc returns a func that takes a cli.Context +// and returns an InputSourceContext suitable for retrieving config +// variables from a file containing JSON data with the file name defined +// by the given flag. +func NewJSONSourceFromFlagFunc(flag string) func(c *cli.Context) (InputSourceContext, error) { + return func(context *cli.Context) (InputSourceContext, error) { + return NewJSONSourceFromFile(context.String(flag)) + } +} + +// NewJSONSourceFromFile returns an InputSourceContext suitable for +// retrieving config variables from a file (or url) containing JSON +// data. +func NewJSONSourceFromFile(f string) (InputSourceContext, error) { + data, err := loadDataFrom(f) + if err != nil { + return nil, err + } + return NewJSONSource(data) +} + +// NewJSONSourceFromReader returns an InputSourceContext suitable for +// retrieving config variables from an io.Reader that returns JSON data. +func NewJSONSourceFromReader(r io.Reader) (InputSourceContext, error) { + data, err := ioutil.ReadAll(r) + if err != nil { + return nil, err + } + return NewJSONSource(data) +} + +// NewJSONSource returns an InputSourceContext suitable for retrieving +// config variables from raw JSON data. +func NewJSONSource(data []byte) (InputSourceContext, error) { + var deserialized map[string]interface{} + if err := json.Unmarshal(data, &deserialized); err != nil { + return nil, err + } + return &jsonSource{deserialized: deserialized}, nil +} + +func (x *jsonSource) Int(name string) (int, error) { + i, err := x.getValue(name) + if err != nil { + return 0, err + } + switch v := i.(type) { + default: + return 0, fmt.Errorf("unexpected type %T for %q", i, name) + case int: + return v, nil + case float64: + return int(float64(v)), nil + case float32: + return int(float32(v)), nil + } +} + +func (x *jsonSource) Duration(name string) (time.Duration, error) { + i, err := x.getValue(name) + if err != nil { + return 0, err + } + v, ok := (time.Duration)(0), false + if v, ok = i.(time.Duration); !ok { + return v, fmt.Errorf("unexpected type %T for %q", i, name) + } + return v, nil +} + +func (x *jsonSource) Float64(name string) (float64, error) { + i, err := x.getValue(name) + if err != nil { + return 0, err + } + v, ok := (float64)(0), false + if v, ok = i.(float64); !ok { + return v, fmt.Errorf("unexpected type %T for %q", i, name) + } + return v, nil +} + +func (x *jsonSource) String(name string) (string, error) { + i, err := x.getValue(name) + if err != nil { + return "", err + } + v, ok := "", false + if v, ok = i.(string); !ok { + return v, fmt.Errorf("unexpected type %T for %q", i, name) + } + return v, nil +} + +func (x *jsonSource) StringSlice(name string) ([]string, error) { + i, err := x.getValue(name) + if err != nil { + return nil, err + } + switch v := i.(type) { + default: + return nil, fmt.Errorf("unexpected type %T for %q", i, name) + case []string: + return v, nil + case []interface{}: + c := []string{} + for _, s := range v { + if str, ok := s.(string); ok { + c = append(c, str) + } else { + return c, fmt.Errorf("unexpected item type %T in %T for %q", s, c, name) + } + } + return c, nil + } +} + +func (x *jsonSource) IntSlice(name string) ([]int, error) { + i, err := x.getValue(name) + if err != nil { + return nil, err + } + switch v := i.(type) { + default: + return nil, fmt.Errorf("unexpected type %T for %q", i, name) + case []int: + return v, nil + case []interface{}: + c := []int{} + for _, s := range v { + if i2, ok := s.(int); ok { + c = append(c, i2) + } else { + return c, fmt.Errorf("unexpected item type %T in %T for %q", s, c, name) + } + } + return c, nil + } +} + +func (x *jsonSource) Generic(name string) (cli.Generic, error) { + i, err := x.getValue(name) + if err != nil { + return nil, err + } + v, ok := (cli.Generic)(nil), false + if v, ok = i.(cli.Generic); !ok { + return v, fmt.Errorf("unexpected type %T for %q", i, name) + } + return v, nil +} + +func (x *jsonSource) Bool(name string) (bool, error) { + i, err := x.getValue(name) + if err != nil { + return false, err + } + v, ok := false, false + if v, ok = i.(bool); !ok { + return v, fmt.Errorf("unexpected type %T for %q", i, name) + } + return v, nil +} + +// since this source appears to require all configuration to be specified, the +// concept of a boolean defaulting to true seems inconsistent with no defaults +func (x *jsonSource) BoolT(name string) (bool, error) { + return false, fmt.Errorf("unsupported type BoolT for JSONSource") +} + +func (x *jsonSource) getValue(key string) (interface{}, error) { + return jsonGetValue(key, x.deserialized) +} + +func jsonGetValue(key string, m map[string]interface{}) (interface{}, error) { + var ret interface{} + var ok bool + working := m + keys := strings.Split(key, ".") + for ix, k := range keys { + if ret, ok = working[k]; !ok { + return ret, fmt.Errorf("missing key %q", key) + } + if working, ok = ret.(map[string]interface{}); !ok { + if ix < len(keys)-1 { + return ret, fmt.Errorf("unexpected intermediate value at %q segment of %q: %T", k, key, ret) + } + } + } + return ret, nil +} + +type jsonSource struct { + deserialized map[string]interface{} +}