Skip to content

Commit

Permalink
feat(encoding): add dotenv codec
Browse files Browse the repository at this point in the history
Signed-off-by: Mark Sagi-Kazar <mark.sagikazar@gmail.com>
  • Loading branch information
sagikazarmark committed Sep 19, 2021
1 parent c5914e3 commit c7cbed7
Show file tree
Hide file tree
Showing 3 changed files with 165 additions and 0 deletions.
61 changes: 61 additions & 0 deletions internal/encoding/dotenv/codec.go
@@ -0,0 +1,61 @@
package dotenv

import (
"bytes"
"fmt"
"sort"
"strings"

"github.com/subosito/gotenv"
)

const keyDelimiter = "_"

// Codec implements the encoding.Encoder and encoding.Decoder interfaces for encoding data containing environment variables
// (commonly called as dotenv format).
type Codec struct{}

func (Codec) Encode(v map[string]interface{}) ([]byte, error) {
flattened := map[string]interface{}{}

flattened = flattenAndMergeMap(flattened, v, "", keyDelimiter)

keys := make([]string, 0, len(flattened))

for key := range flattened {
keys = append(keys, key)
}

sort.Strings(keys)

var buf bytes.Buffer

for _, key := range keys {
_, err := buf.WriteString(fmt.Sprintf("%v=%v\n", strings.ToUpper(key), flattened[key]))
if err != nil {
return nil, err
}
}

return buf.Bytes(), nil
}

func (Codec) Decode(b []byte, v map[string]interface{}) error {
var buf bytes.Buffer

_, err := buf.Write(b)
if err != nil {
return err
}

env, err := gotenv.StrictParse(&buf)
if err != nil {
return err
}

for key, value := range env {
v[key] = value
}

return nil
}
63 changes: 63 additions & 0 deletions internal/encoding/dotenv/codec_test.go
@@ -0,0 +1,63 @@
package dotenv

import (
"reflect"
"testing"
)

// original form of the data
const original = `# key-value pair
KEY=value
`

// encoded form of the data
const encoded = `KEY=value
`

// Viper's internal representation
var data = 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) {
codec := Codec{}

v := map[string]interface{}{}

err := codec.Decode([]byte(`invalid data`), v)
if err == nil {
t.Fatal("expected decoding to fail")
}

t.Logf("decoding failed as expected: %s", err)
})
}
41 changes: 41 additions & 0 deletions internal/encoding/dotenv/map_utils.go
@@ -0,0 +1,41 @@
package dotenv

import (
"strings"

"github.com/spf13/cast"
)

// 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
}

0 comments on commit c7cbed7

Please sign in to comment.