diff --git a/internal/encoding/javaproperties/codec.go b/internal/encoding/javaproperties/codec.go new file mode 100644 index 0000000..ac3b66b --- /dev/null +++ b/internal/encoding/javaproperties/codec.go @@ -0,0 +1,77 @@ +package javaproperties + +import ( + "bytes" + "sort" + "strings" + + "github.com/magiconair/properties" + "github.com/spf13/cast" +) + +// Codec implements the encoding.Encoder and encoding.Decoder interfaces for Java properties encoding. +type Codec struct { + KeyDelimiter string +} + +func (c Codec) Encode(v map[string]interface{}) ([]byte, error) { + p := properties.NewProperties() + + flattened := map[string]interface{}{} + + flattened = flattenAndMergeMap(flattened, v, "", c.keyDelimiter()) + + keys := make([]string, 0, len(flattened)) + + for key := range flattened { + keys = append(keys, key) + } + + sort.Strings(keys) + + for _, key := range keys { + _, _, err := p.Set(key, cast.ToString(flattened[key])) + if err != nil { + return nil, err + } + } + + var buf bytes.Buffer + + _, err := p.WriteComment(&buf, "#", properties.UTF8) + if err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +func (c Codec) Decode(b []byte, v map[string]interface{}) error { + p, err := properties.Load(b, properties.UTF8) + if err != nil { + return err + } + + for _, key := range p.Keys() { + // ignore existence check: we know it's there + value, _ := p.Get(key) + + // recursively build nested maps + path := strings.Split(key, c.keyDelimiter()) + lastKey := strings.ToLower(path[len(path)-1]) + deepestMap := deepSearch(v, path[0:len(path)-1]) + + // set innermost value + deepestMap[lastKey] = value + } + + return nil +} + +func (c Codec) keyDelimiter() string { + if c.KeyDelimiter == "" { + return "." + } + + return c.KeyDelimiter +} diff --git a/internal/encoding/javaproperties/codec_test.go b/internal/encoding/javaproperties/codec_test.go new file mode 100644 index 0000000..8009707 --- /dev/null +++ b/internal/encoding/javaproperties/codec_test.go @@ -0,0 +1,69 @@ +package javaproperties + +import ( + "reflect" + "testing" +) + +// original form of the data +const original = `# key-value pair +key = value +map.key = value +` + +// encoded form of the data +const encoded = `key = value +map.key = value +` + +// Viper's internal representation +var data = map[string]interface{}{ + "key": "value", + "map": map[string]interface{}{ + "key": "value", + }, +} + +func TestCodec_Encode(t *testing.T) { + codec := Codec{} + + b, err := codec.Encode(data) + if err != nil { + t.Fatal(err) + } + + if encoded != string(b) { + t.Fatalf("decoded value does not match the expected one\nactual: %#v\nexpected: %#v", string(b), encoded) + } +} + +func TestCodec_Decode(t *testing.T) { + t.Run("OK", func(t *testing.T) { + codec := Codec{} + + v := map[string]interface{}{} + + err := codec.Decode([]byte(original), v) + if err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(data, v) { + t.Fatalf("decoded value does not match the expected one\nactual: %#v\nexpected: %#v", v, data) + } + }) + + t.Run("InvalidData", func(t *testing.T) { + t.Skip("TODO: needs invalid data example") + + codec := Codec{} + + v := map[string]interface{}{} + + codec.Decode([]byte(``), v) + + if len(v) > 0 { + t.Fatalf("expected map to be empty when data is invalid\nactual: %#v", v) + } + }) +} diff --git a/internal/encoding/javaproperties/map_utils.go b/internal/encoding/javaproperties/map_utils.go new file mode 100644 index 0000000..93755ca --- /dev/null +++ b/internal/encoding/javaproperties/map_utils.go @@ -0,0 +1,74 @@ +package javaproperties + +import ( + "strings" + + "github.com/spf13/cast" +) + +// THIS CODE IS COPIED HERE: IT SHOULD NOT BE MODIFIED +// AT SOME POINT IT WILL BE MOVED TO A COMMON PLACE +// deepSearch scans deep maps, following the key indexes listed in the +// sequence "path". +// The last value is expected to be another map, and is returned. +// +// In case intermediate keys do not exist, or map to a non-map value, +// a new map is created and inserted, and the search continues from there: +// the initial map "m" may be modified! +func deepSearch(m map[string]interface{}, path []string) map[string]interface{} { + for _, k := range path { + m2, ok := m[k] + if !ok { + // intermediate key does not exist + // => create it and continue from there + m3 := make(map[string]interface{}) + m[k] = m3 + m = m3 + continue + } + m3, ok := m2.(map[string]interface{}) + if !ok { + // intermediate key is a value + // => replace with a new map + m3 = make(map[string]interface{}) + m[k] = m3 + } + // continue search from here + m = m3 + } + return m +} + +// flattenAndMergeMap recursively flattens the given map into a new map +// Code is based on the function with the same name in tha main package. +// TODO: move it to a common place +func flattenAndMergeMap(shadow map[string]interface{}, m map[string]interface{}, prefix string, delimiter string) map[string]interface{} { + if shadow != nil && prefix != "" && shadow[prefix] != nil { + // prefix is shadowed => nothing more to flatten + return shadow + } + if shadow == nil { + shadow = make(map[string]interface{}) + } + + var m2 map[string]interface{} + if prefix != "" { + prefix += delimiter + } + for k, val := range m { + fullKey := prefix + k + switch val.(type) { + case map[string]interface{}: + m2 = val.(map[string]interface{}) + case map[interface{}]interface{}: + m2 = cast.ToStringMap(val) + default: + // immediate value + shadow[strings.ToLower(fullKey)] = val + continue + } + // recursively merge to shadow map + shadow = flattenAndMergeMap(shadow, m2, fullKey, delimiter) + } + return shadow +}