diff --git a/duration.go b/duration.go new file mode 100644 index 0000000..7b473bf --- /dev/null +++ b/duration.go @@ -0,0 +1,61 @@ +package cnfgfile + +import ( + "encoding" + "encoding/json" + "fmt" + "time" +) + +/*** This code also exists in the golift.io/cnfg package. It is identical. ***/ + +// Duration is useful if you need to load a time Duration from a config file into +// your application. Use the config.Duration type to support automatic unmarshal +// from all sources. +type Duration struct{ time.Duration } + +// UnmarshalText parses a duration type from a config file. This method works +// with the Duration type to allow unmarshaling of durations from files and +// env variables in the same struct. You won't generally call this directly. +func (d *Duration) UnmarshalText(b []byte) error { + dur, err := time.ParseDuration(string(b)) + if err != nil { + return fmt.Errorf("parsing duration '%s': %w", b, err) + } + + d.Duration = dur + + return nil +} + +// MarshalText returns the string representation of a Duration. ie. 1m32s. +func (d Duration) MarshalText() ([]byte, error) { + return []byte(d.Duration.String()), nil +} + +// MarshalJSON returns the string representation of a Duration for JSON. ie. "1m32s". +func (d Duration) MarshalJSON() ([]byte, error) { + return []byte(`"` + d.Duration.String() + `"`), nil +} + +// String returns a Duration as string without trailing zero units. +func (d Duration) String() string { + dur := d.Duration.String() + if len(dur) > 3 && dur[len(dur)-3:] == "m0s" { + dur = dur[:len(dur)-2] + } + + if len(dur) > 3 && dur[len(dur)-3:] == "h0m" { + dur = dur[:len(dur)-2] + } + + return dur +} + +// Make sure our struct satisfies the interface it's for. +var ( + _ encoding.TextUnmarshaler = (*Duration)(nil) + _ encoding.TextMarshaler = (*Duration)(nil) + _ json.Marshaler = (*Duration)(nil) + _ fmt.Stringer = (*Duration)(nil) +) diff --git a/file.go b/file.go index 4abef2c..4e9be0e 100644 --- a/file.go +++ b/file.go @@ -20,7 +20,6 @@ import ( "net/http" "os" "strings" - "time" toml "github.com/BurntSushi/toml" yaml "gopkg.in/yaml.v3" @@ -29,28 +28,9 @@ import ( // Errors this library may produce. var ( ErrNoFile = errors.New("must provide at least 1 file to unmarshal") - ErrNotPtr = errors.New("must provide a pointer to a struct") + ErrNotPtr = errors.New("ReadConfigs: must provide a pointer to a struct") ) -// Duration allows unmarshalling time durations from a config file. -type Duration struct { - time.Duration -} - -// UnmarshalText parses a duration type from a config file. This method works -// with the Duration type to allow unmarshaling of durations from files and -// env variables in the same struct. You won't generally call this directly. -func (d *Duration) UnmarshalText(b []byte) error { - dur, err := time.ParseDuration(string(b)) - if err != nil { - return fmt.Errorf("parsing duration '%s': %w", b, err) - } - - d.Duration = dur - - return nil -} - // Unmarshal parses a configuration file (of any format) into a config struct. // This is a shorthand method for calling Unmarshal against the json, xml, yaml // or toml packages. If the file name contains an appropriate suffix it is diff --git a/file_test.go b/file_test.go index 71bcd3f..32e4e25 100644 --- a/file_test.go +++ b/file_test.go @@ -186,5 +186,5 @@ func ExampleUnmarshal() { } fmt.Printf("interval: %v, location: %v, provided: %v", config.Interval, config.Location, config.Provided) - // Output: interval: 5m0s, location: Earth, provided: true + // Output: interval: 5m, location: Earth, provided: true } diff --git a/filepath.go b/filepath.go index ed75222..bf370e3 100644 --- a/filepath.go +++ b/filepath.go @@ -9,12 +9,12 @@ import ( "strings" ) -// Changes contains the optional input parameters for ReadConfigs() to control how a data structure is processed. -type Changes struct { +// Opts contains the optional input parameters for ReadConfigs() to control how a data structure is processed. +type Opts struct { // Prefix is the string we check for to see if we should read in a config file. // If left blank the default of filepath: will be used. Prefix string - // The maximum amount of data we should read in from a config file. + // The maximum amount of bytes that should read in from an external config file. // If you don't expect large values, leave this small. // If left at 0, the default of 1024 is used. MaxSize uint @@ -37,35 +37,36 @@ const ( // then the provided filepath is opened, read, and the contents are saved into the string. Replacing the filepath // that it once was. This allows you to define a Config struct, and your users can store secrets (or other strings) // in separate files. After you read in the base config data, pass a pointer to your config struct to this function, -// and it will automatically go to work filling in any extra external config data. -func ReadConfigs(data interface{}, changes *Changes) error { - if rd := reflect.TypeOf(data); rd.Kind() != reflect.Ptr || rd.Elem().Kind() != reflect.Struct { - return fmt.Errorf("ReadConfigs: %w", ErrNotPtr) +// and it will automatically go to work filling in any extra external config data. Opts may be nil, uses defaults. +func ReadConfigs(input interface{}, opts *Opts) error { + data := reflect.TypeOf(input) + if data.Kind() != reflect.Ptr || data.Elem().Kind() != reflect.Struct { + return ErrNotPtr } - if changes == nil { - changes = &Changes{} + if opts == nil { + opts = &Opts{} } - if changes.MaxSize == 0 { - changes.MaxSize = DefaultMaxSize + if opts.MaxSize == 0 { + opts.MaxSize = DefaultMaxSize } - if changes.Prefix == "" { - changes.Prefix = DefaultPrefix + if opts.Prefix == "" { + opts.Prefix = DefaultPrefix } - if changes.Name == "" { - changes.Name = DefaultName + if opts.Name == "" { + opts.Name = DefaultName } - return changes.parseStruct(reflect.ValueOf(data).Elem(), changes.Name) + return opts.parseStruct(reflect.ValueOf(input).Elem(), opts.Name) } -func (c *Changes) parseStruct(rv reflect.Value, name string) error { - for i := rv.NumField() - 1; i >= 0; i-- { - err := c.parseElement(rv.Field(i), name+"."+rv.Type().Field(i).Name) - if err != nil { +func (o *Opts) parseStruct(field reflect.Value, name string) error { + for i := field.NumField() - 1; i >= 0; i-- { + name := name + "." + field.Type().Field(i).Name // name is overloaded here. + if err := o.parseElement(field.Field(i), name); err != nil { return err } } @@ -73,24 +74,30 @@ func (c *Changes) parseStruct(rv reflect.Value, name string) error { return nil } -func (c *Changes) parseMap(field reflect.Value, name string) error { +func (o *Opts) parseMap(field reflect.Value, name string) error { for _, key := range field.MapKeys() { - value := reflect.Indirect(reflect.New(field.MapIndex(key).Type())) - value.Set(field.MapIndex(key)) - - if err := c.parseElement(value, fmt.Sprint(name, key)); err != nil { + // Copy the map field. + fieldCopy := reflect.Indirect(reflect.New(field.MapIndex(key).Type())) + // Set the copy's value to the value of the original. + fieldCopy.Set(field.MapIndex(key)) + + // Parse the copy, because map values cannot be .Set() directly. + name := fmt.Sprint(name, "[", key, "]") // name is overloaded here. + if err := o.parseElement(fieldCopy, name); err != nil { return err } - field.SetMapIndex(key, value) + // Update the map index with the possibly-modified copy that got parsed. + field.SetMapIndex(key, fieldCopy) } return nil } -func (c *Changes) parseSlice(field reflect.Value, name string) error { - for i := field.Len() - 1; i >= 0; i-- { - if err := c.parseElement(field.Index(i), fmt.Sprint(name, i)); err != nil { +func (o *Opts) parseSlice(field reflect.Value, name string) error { + for idx := field.Len() - 1; idx >= 0; idx-- { + name := fmt.Sprint(name, "[", idx+1, "/", field.Len(), "]") // name is overloaded here. + if err := o.parseElement(field.Index(idx), name); err != nil { return err } } @@ -98,35 +105,36 @@ func (c *Changes) parseSlice(field reflect.Value, name string) error { return nil } -func (c *Changes) parseElement(field reflect.Value, name string) error { +// parseElement processes any supported element type. +func (o *Opts) parseElement(field reflect.Value, name string) error { switch kind := field.Kind(); kind { case reflect.String: - return c.parseString(field, name) + return o.parseString(field, name) case reflect.Struct: - return c.parseStruct(field, name) + return o.parseStruct(field, name) case reflect.Pointer, reflect.Interface: - return c.parseElement(field.Elem(), name) + return o.parseElement(field.Elem(), name) case reflect.Slice, reflect.Array: - return c.parseSlice(field, name) + return o.parseSlice(field, name) case reflect.Map: - return c.parseMap(field, name) + return o.parseMap(field, name) default: return nil } } -func (c *Changes) parseString(field reflect.Value, name string) error { +func (o *Opts) parseString(field reflect.Value, name string) error { value := field.String() - if !strings.HasPrefix(value, c.Prefix) { + if !strings.HasPrefix(value, o.Prefix) { return nil } - data, err := readFile(strings.TrimPrefix(value, c.Prefix), c.MaxSize) + data, err := readFile(strings.TrimPrefix(value, o.Prefix), o.MaxSize) if err != nil { return fmt.Errorf("element failure: %s: %w", name, err) } - if c.NoTrim { + if o.NoTrim { field.SetString(data) } else { field.SetString(strings.TrimSpace(data)) diff --git a/filepath_test.go b/filepath_test.go index 7c9932e..a5a2e26 100644 --- a/filepath_test.go +++ b/filepath_test.go @@ -12,10 +12,12 @@ import ( const testString = "hi, this is a string\n" -type Struct struct { +type TestStruct struct { EmbedName string EmbedAddress string EmbedNumber int + MemberName []String + StarStruck *TestStruct } type dataStruct struct { @@ -27,71 +29,143 @@ type dataStruct struct { EmbedAddress string EmbedNumber int } - Struct - Named *Struct + TestStruct + Named *TestStruct Map map[string]string MapI map[int]string + LulWut map[interface{}][]*TestStruct Strings []string - Structs []Struct - Ptructs []*Struct + Structs []TestStruct + Ptructs []*TestStruct + Etring String + String + StrPtr *String } +type String string + func TestReadConfigs(t *testing.T) { t.Parallel() file := makeTextFile(t) defer os.Remove(file) - data := dataStruct{ + data := testData(t, file) + testString := strings.TrimSuffix(testString, "\n") + + require.NoError(t, cnfgfile.ReadConfigs(&data, nil), "got an unexpected error") + assert.EqualValues(t, testString, data.Address) + assert.EqualValues(t, testString, data.Embed.EmbedAddress) + assert.EqualValues(t, testString, data.Named.EmbedAddress) + assert.EqualValues(t, testString, data.TestStruct.EmbedAddress) + assert.EqualValues(t, testString, data.Strings[1]) + assert.EqualValues(t, testString, data.Structs[0].EmbedAddress) + assert.EqualValues(t, testString, data.Ptructs[0].EmbedAddress) + assert.EqualValues(t, testString, data.String) + assert.EqualValues(t, testString, data.Etring) + assert.EqualValues(t, testString, *data.StrPtr) + + assert.EqualValues(t, testString, data.Map["map_string"]) + assert.EqualValues(t, "data stuff", data.Map["map2_string"]) + assert.EqualValues(t, testString, data.MapI[2], "an unexpected change was made to a string") + assert.EqualValues(t, "data stuff", data.MapI[5], "an unexpected change was made to a string") + + data.Name = "super:" + file + require.NoError(t, cnfgfile.ReadConfigs(&data, &cnfgfile.Opts{Prefix: "super:", MaxSize: 8})) + assert.Equal(t, testString[:8], data.Name, "opts.MaxSize doesn't seem to be working") +} + +func TestReadConfigsErrors(t *testing.T) { + t.Parallel() + + file := makeTextFile(t) + defer os.Remove(file) + + data := testData(t, file) + opts := &cnfgfile.Opts{ + Prefix: "super:", + MaxSize: 8, + NoTrim: true, + Name: "MyThing", + } + + require.ErrorIs(t, cnfgfile.ReadConfigs(data, opts), cnfgfile.ErrNotPtr) + + data.Name = "super:/no_file" + // This test: + // makes sure the correct opts.Prefix is used. + // makes sure the proper opts.Name is used. + // makes sure a missing file returns a useful error. + require.ErrorContains(t, cnfgfile.ReadConfigs(&data, opts), + "element failure: MyThing.Name: opening file: open /no_file:", + "this may indicate the wrong prefix or name is being used") + + data.Name = "" + data.Map["MAPKEY"] = "super:/no_file" + require.ErrorContains(t, cnfgfile.ReadConfigs(&data, opts), + "element failure: MyThing.Map[MAPKEY]: opening file: open /no_file:", + "this may indicate the wrong prefix or name is being used") + + delete(data.Map, "MAPKEY") + data.LulWut = map[interface{}][]*TestStruct{"some_key": {nil, {EmbedName: "super:/no_file"}, nil}} + require.ErrorContains(t, cnfgfile.ReadConfigs(&data, opts), + "element failure: MyThing.LulWut[some_key][2/3].EmbedName: opening file: open /no_file:", + "this test fails is the member names are not concatenated properly") + + data.LulWut = map[interface{}][]*TestStruct{ + String("flop"): {nil, {StarStruck: &TestStruct{MemberName: []String{"super:/no_file", ""}}}}, + } + require.ErrorContains(t, cnfgfile.ReadConfigs(&data, opts), + "element failure: MyThing.LulWut[flop][2/2].StarStruck.MemberName[1/2]: opening file: open /no_file:", + "this test fails is the member names are not concatenated properly") +} + +// testData returns a test struct filled with filepaths. +// We test strings, structs, maps, slices, pointers... +func testData(t *testing.T, file string) dataStruct { + t.Helper() + + str := String(cnfgfile.DefaultPrefix + file) + + return dataStruct{ Name: "me", - Address: "filepath:" + file, + Address: cnfgfile.DefaultPrefix + file, Embed: struct { EmbedName string EmbedAddress string EmbedNumber int }{ - EmbedAddress: "filepath:" + file, + EmbedAddress: cnfgfile.DefaultPrefix + file, }, - Struct: Struct{ + TestStruct: TestStruct{ EmbedName: "me2", - EmbedAddress: "filepath:" + file, + EmbedAddress: cnfgfile.DefaultPrefix + file, }, - Named: &Struct{ + Named: &TestStruct{ EmbedName: "me3", - EmbedAddress: "filepath:" + file, + EmbedAddress: cnfgfile.DefaultPrefix + file, }, Map: map[string]string{ - "map_string": "filepath:" + file, + "map_string": cnfgfile.DefaultPrefix + file, "map2_string": "data stuff", }, MapI: map[int]string{ - 2: "filepath:" + file, + 2: cnfgfile.DefaultPrefix + file, 5: "data stuff", }, - Strings: []string{"foo", "filepath:" + file}, - Structs: []Struct{{ + Strings: []string{"foo", cnfgfile.DefaultPrefix + file}, + Structs: []TestStruct{{ EmbedName: "me4", - EmbedAddress: "filepath:" + file, + EmbedAddress: cnfgfile.DefaultPrefix + file, }}, - Ptructs: []*Struct{{ + Ptructs: []*TestStruct{{ EmbedName: "me5", - EmbedAddress: "filepath:" + file, + EmbedAddress: cnfgfile.DefaultPrefix + file, }}, + String: String(cnfgfile.DefaultPrefix + file), + Etring: String(cnfgfile.DefaultPrefix + file), + StrPtr: &str, } - testString := strings.TrimSuffix(testString, "\n") - - require.NoError(t, cnfgfile.ReadConfigs(&data, nil), "got an unexpected error") - assert.EqualValues(t, testString, data.Address) - assert.EqualValues(t, testString, data.Embed.EmbedAddress) - assert.EqualValues(t, testString, data.Named.EmbedAddress) - assert.EqualValues(t, testString, data.Struct.EmbedAddress) - assert.EqualValues(t, testString, data.Strings[1]) - assert.EqualValues(t, testString, data.Structs[0].EmbedAddress) - assert.EqualValues(t, testString, data.Ptructs[0].EmbedAddress) - assert.EqualValues(t, testString, data.Map["map_string"]) - assert.EqualValues(t, "data stuff", data.Map["map2_string"]) - assert.EqualValues(t, testString, data.MapI[2], "an unexpected change was made to a string") - assert.EqualValues(t, "data stuff", data.MapI[5], "an unexpected change was made to a string") } func makeTextFile(t *testing.T) string {