commit cfd661ceb435972f1dba0c37e9f30fd5cb180010 Author: Bruno Carlin Date: Tue Mar 17 12:30:59 2020 +0100 initial commit diff --git a/config.go b/config.go new file mode 100644 index 0000000..79ca2c6 --- /dev/null +++ b/config.go @@ -0,0 +1,84 @@ +// Package config defines utils to simplify configuration +// management +package conf + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "os" + "path/filepath" +) + +// LoadFile reads the file at path, parses its json content and fills the struct +// with the content of the file. +func LoadFile(path string, data interface{}) error { + return read(path, data) +} + +// SaveFile writes the given data serialized in JSON in the given path +func SaveFile(path string, data interface{}) error { + return write(path, data) +} + +// LoadAndUpdateFile reads the config fileat path and +// updates it, meaning that it adds new options, removes +// old ones, and update it by calling the Update method of +// data if it implements the interface Updater. +// +// If no file is found at path, it is created and +// initialized with the default values. +// +// An error is returned only if the config file cannot be +// written. +func LoadAndUpdateFile(path string, data interface{}) error { + if _, err := os.Stat(path); !os.IsNotExist(err) { + err2 := read(path, data) + if err2 != nil { + return err2 + } + + if data, ok := data.(Updater); ok { + data.Update() + } + } + + return write(path, data) +} + +// Updater is the interface that can be implemented by +// config structs. If it is implemented, Update() is +// called by LoadAndUpdateFile(). It allows one to modify +// the data and persist those changes, for example to +// change default values +type Updater interface { + Update() +} + +func read(path string, data interface{}) error { + content, err := ioutil.ReadFile(filepath.Clean(path)) + if err != nil { + return fmt.Errorf("cannot read config file: %w", err) + } + + err = json.Unmarshal(content, &data) + if err != nil { + return fmt.Errorf("cannot parse config file: %w", err) + } + + return nil +} + +func write(path string, data interface{}) error { + content, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("cannot generate config content: %w", err) + } + + err = ioutil.WriteFile(path, content, 0644) + if err != nil { + return fmt.Errorf("cannot write config file '%s': %w", path, err) + } + + return nil +} diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..8812b03 --- /dev/null +++ b/config_test.go @@ -0,0 +1,313 @@ +package conf + +import ( + "io/ioutil" + "os" + "testing" + + . "github.com/smartystreets/goconvey/convey" +) + +type testconf struct { + String string + Int int + Invariant string + inUpdate func() +} + +func (t testconf) Update() { + if t.inUpdate != nil { + t.inUpdate() + } +} + +func TestLoadFile(t *testing.T) { + Convey("Given a config struct", t, func() { + c := testconf{ + String: "default string", + Int: 1, + Invariant: "should not change", + } + + Convey("Given a valid config file", func() { + file := "test.conf" + content := []byte(`{"String": "config string", "Int": 42}`) + err := ioutil.WriteFile(file, content, 0o644) + So(err, ShouldBeNil) + defer func() { _ = os.Remove(file) }() + + Convey("When the config file is loaded", func() { + err := LoadFile(file, &c) + + Convey("Then there is no error", func() { + So(err, ShouldBeNil) + }) + + Convey("Then the struct fields are filled", func() { + So(c.String, ShouldEqual, "config string") + So(c.Int, ShouldEqual, 42) + }) + + Convey("Then the default values are kept", func() { + So(c.Invariant, ShouldEqual, "should not change") + }) + }) + }) + + Convey("Given an invalid config file", func() { + file := "test.conf" + content := []byte(`String: not json`) + err := ioutil.WriteFile(file, content, 0o644) + So(err, ShouldBeNil) + defer func() { _ = os.Remove(file) }() + + Convey("When the config file is loaded", func() { + err := LoadFile(file, &c) + + Convey("Then an error is returned", func() { + So(err, ShouldBeError) + }) + + Convey("Then the struct fields are not filled", func() { + So(c.String, ShouldEqual, "default string") + So(c.Int, ShouldEqual, 1) + }) + }) + }) + + Convey("Given a non existent config file", func() { + file := "does-not-exist.conf.conf" + + Convey("When the config file is loaded", func() { + err := LoadFile(file, &c) + + Convey("Then an error is returned", func() { + So(err, ShouldBeError) + }) + + Convey("Then the struct fields are not filled", func() { + So(c.String, ShouldEqual, "default string") + So(c.Int, ShouldEqual, 1) + }) + }) + }) + }) +} + +func TestSaveFile(t *testing.T) { + Convey("Given a config struct", t, func() { + c := testconf{ + String: "default string", + Int: 1, + Invariant: "should not change", + } + + Convey("Given a valid path", func() { + file := "test.conf" + + Convey("When the config file is saved", func() { + err := SaveFile(file, &c) + defer func() { _ = os.Remove(file) }() + + Convey("Then there is no error", func() { + So(err, ShouldBeNil) + }) + + Convey("Then the file exists", func() { + _, err := os.Stat(file) + So(err, ShouldBeNil) + + Convey("Then the content is correct", func() { + expected := "{\n \"String\": \"default string\",\n \"Int\": 1,\n \"Invariant\": \"should not change\"\n}" + got, err := ioutil.ReadFile(file) + So(err, ShouldBeNil) + So(string(got), ShouldEqual, expected) + }) + }) + }) + + Convey("When the config file is saved with invalid data", func() { + err := SaveFile(file, func() error { return nil }) + defer func() { _ = os.Remove(file) }() + + Convey("Then an error is returned", func() { + So(err, ShouldBeError) + }) + + Convey("Then the file does not exist", func() { + _, err := os.Stat(file) + So(os.IsNotExist(err), ShouldBeTrue) + }) + }) + }) + + Convey("Given an invalid path", func() { + file := "cannot/write/here.conf" + + Convey("When the config file is loaded", func() { + err := SaveFile(file, &c) + + Convey("Then an error is returned", func() { + So(err, ShouldBeError) + }) + + Convey("Then the file does not exist", func() { + _, err := os.Stat(file) + So(os.IsNotExist(err), ShouldBeTrue) + }) + }) + }) + }) +} + +func TestLoadAndUpdateFile(t *testing.T) { + Convey("Given a config struct and a path", t, func() { + updated := false + + c := testconf{ + String: "default string", + Int: 1, + Invariant: "should not change", + } + c.inUpdate = func() { updated = true } + file := "test.conf" + + Convey("Given no file exists at this path", func() { + _, err := os.Stat(file) + So(err, ShouldBeError) + So(os.IsNotExist(err), ShouldBeTrue) + + Convey("When LoadAndUpdateFile is called", func() { + err := LoadAndUpdateFile(file, &c) + So(err, ShouldBeNil) + defer func() { _ = os.Remove(file) }() + + Convey("Then the config file has been created", func() { + _, err := os.Stat(file) + So(err, ShouldBeNil) + + Convey("and it has the default configuration", func() { + var c2 testconf + err := read(file, &c2) + So(err, ShouldBeNil) + c.inUpdate = nil + So(c2, ShouldResemble, c) + }) + }) + }) + }) + + Convey("Given the path cannot be written", func() { + file = "does-not-exist/test.conf" + _, err := os.Stat(file) + So(err, ShouldBeError) + So(os.IsNotExist(err), ShouldBeTrue) + + Convey("When LoadAndUpdateFile is called", func() { + err := LoadAndUpdateFile(file, &c) + defer func() { _ = os.Remove(file) }() + + Convey("Then an error is returned", func() { + So(err, ShouldBeError) + }) + }) + }) + + Convey("Given a config file with custom values", func() { + content := []byte(`{"String": "config string", "Int": 42}`) + err := ioutil.WriteFile(file, content, 0o644) + So(err, ShouldBeNil) + defer func() { _ = os.Remove(file) }() + + Convey("When LoadAndUpdateFile is called", func() { + err := LoadAndUpdateFile(file, &c) + + Convey("Then there is no error", func() { + So(err, ShouldBeNil) + }) + + Convey("Then the struct fields are filled", func() { + So(c.String, ShouldEqual, "config string") + So(c.Int, ShouldEqual, 42) + }) + }) + }) + + Convey("Given an invalid configuration file", func() { + content := []byte(`String: not json`) + err := ioutil.WriteFile(file, content, 0o644) + So(err, ShouldBeNil) + defer func() { _ = os.Remove(file) }() + + Convey("When LoadAndUpdateFile is called", func() { + err := LoadAndUpdateFile(file, &c) + defer func() { _ = os.Remove(file) }() + + Convey("Then an error is returned", func() { + So(err, ShouldBeError) + }) + }) + }) + + Convey("Given a config file with missing options", func() { + content := []byte(`{"String": "config string"}`) + err := ioutil.WriteFile(file, content, 0o644) + So(err, ShouldBeNil) + defer func() { _ = os.Remove(file) }() + + Convey("When LoadAndUpdateFile is called", func() { + err := LoadAndUpdateFile(file, &c) + + Convey("Then there is no error", func() { + So(err, ShouldBeNil) + }) + + Convey("Then the config file has been updated with new options", func() { + content, err := ioutil.ReadFile(file) + So(err, ShouldBeNil) + So(string(content), ShouldContainSubstring, `"Int"`) + }) + }) + }) + + Convey("Given a config file with old options", func() { + content := []byte(`{"String": "config string", "Foo": "blah"}`) + err := ioutil.WriteFile(file, content, 0o644) + So(err, ShouldBeNil) + defer func() { _ = os.Remove(file) }() + + Convey("When LoadAndUpdateFile is called", func() { + err := LoadAndUpdateFile(file, &c) + + Convey("Then there is no error", func() { + So(err, ShouldBeNil) + }) + + Convey("Then the config file has been updated with new options", func() { + content, err := ioutil.ReadFile(file) + So(err, ShouldBeNil) + So(string(content), ShouldNotContainSubstring, `"Foo"`) + }) + }) + }) + + Convey("Given a valid config file", func() { + content := []byte(`{"String": "config string"}`) + err := ioutil.WriteFile(file, content, 0o644) + So(err, ShouldBeNil) + defer func() { _ = os.Remove(file) }() + + Convey("When LoadAndUpdateFile is called", func() { + err := LoadAndUpdateFile(file, &c) + + Convey("Then there is no error", func() { + So(err, ShouldBeNil) + }) + + Convey("Then the update method of the struct has been called", func() { + So(updated, ShouldBeTrue) + }) + }) + }) + }) +}