From 217b629c45606341f711b84e601e9badbf7cd71e Mon Sep 17 00:00:00 2001 From: RestartFU Date: Thu, 2 Oct 2025 22:46:28 -0400 Subject: [PATCH] feat: add dotenv struct support to DotenvMarshaler --- config.go | 2 +- dotenv.go | 134 ++++++++++++++++++++++++++++++++++++++++ go.mod | 1 + go.sum | 2 + gophig_test.go | 11 ++-- marshal.go | 6 +- tests/assets/sample.env | 3 + 7 files changed, 152 insertions(+), 7 deletions(-) create mode 100644 dotenv.go create mode 100644 tests/assets/sample.env diff --git a/config.go b/config.go index 48bd2bf..6d875bb 100644 --- a/config.go +++ b/config.go @@ -90,7 +90,7 @@ func extractContextValues(ctx context.Context) (string, Marshaler, error) { } if len(missing) > 0 { - return "", marshaler, errors.New(fmt.Sprintf("missing required values in context: %s", strings.Join(missing, ","))) + return "", marshaler, fmt.Errorf("missing required values in context: %s", strings.Join(missing, ",")) } return name, marshaler, nil } diff --git a/dotenv.go b/dotenv.go new file mode 100644 index 0000000..0796a57 --- /dev/null +++ b/dotenv.go @@ -0,0 +1,134 @@ +package gophig + +import ( + "fmt" + "reflect" + "strings" + + "github.com/joho/godotenv" +) + +type DotenvMarshaler struct{} + +func (DotenvMarshaler) Marshal(v any) ([]byte, error) { + rv := reflect.ValueOf(v) + if rv.Kind() == reflect.Pointer { + rv = rv.Elem() + } + if !rv.IsValid() { + return nil, fmt.Errorf("DotenvMarshaler: Marshal expects a valid value") + } + + var sb strings.Builder + + switch rv.Kind() { + case reflect.Map: + m, ok := v.(map[string]string) + if !ok { + return nil, fmt.Errorf("DotenvMarshaler: Marshal expects map[string]string") + } + for key, val := range m { + val = strings.ReplaceAll(val, "\n", `\n`) + sb.WriteString(fmt.Sprintf("%s=%s\n", key, val)) + } + + case reflect.Struct: + rt := rv.Type() + for i := 0; i < rt.NumField(); i++ { + field := rt.Field(i) + fv := rv.Field(i) + if !fv.CanInterface() { + continue + } + + key := field.Tag.Get("env") + if key == "" { + continue // skip fields without env tag + } + + val := fmt.Sprintf("%v", fv.Interface()) + val = strings.ReplaceAll(val, "\n", `\n`) + sb.WriteString(fmt.Sprintf("%s=%s\n", key, val)) + } + + default: + return nil, fmt.Errorf("DotenvMarshaler: unsupported type %s", rv.Kind()) + } + + return []byte(sb.String()), nil +} + +func (DotenvMarshaler) Unmarshal(data []byte, v any) error { + envMap, err := godotenv.Unmarshal(string(data)) + if err != nil { + return fmt.Errorf("DotenvMarshaler: failed to unmarshal dotenv: %w", err) + } + + rv := reflect.ValueOf(v) + if rv.Kind() != reflect.Pointer || rv.IsNil() { + return fmt.Errorf("DotenvMarshaler: Unmarshal expects a non-nil pointer") + } + + rvElem := rv.Elem() + + switch rvElem.Kind() { + case reflect.Map: + if rvElem.Type().Key().Kind() != reflect.String || rvElem.Type().Elem().Kind() != reflect.String { + return fmt.Errorf("DotenvMarshaler: only map[string]string supported") + } + rvElem.Set(reflect.ValueOf(envMap)) + + case reflect.Struct: + for i := 0; i < rvElem.NumField(); i++ { + field := rvElem.Type().Field(i) + tag := field.Tag.Get("env") + if tag == "" { + tag = strings.ToUpper(field.Name) + } + if val, ok := envMap[tag]; ok { + f := rvElem.Field(i) + if f.CanSet() { + switch f.Kind() { + case reflect.String: + f.SetString(val) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + var intVal int64 + _, err := fmt.Sscan(val, &intVal) + if err != nil { + return fmt.Errorf("DotenvMarshaler: failed to parse int for field %s: %w", field.Name, err) + } + f.SetInt(intVal) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + var uintVal uint64 + _, err := fmt.Sscan(val, &uintVal) + if err != nil { + return fmt.Errorf("DotenvMarshaler: failed to parse uint for field %s: %w", field.Name, err) + } + f.SetUint(uintVal) + case reflect.Bool: + var boolVal bool + _, err := fmt.Sscan(val, &boolVal) + if err != nil { + return fmt.Errorf("DotenvMarshaler: failed to parse bool for field %s: %w", field.Name, err) + } + f.SetBool(boolVal) + case reflect.Float32, reflect.Float64: + var floatVal float64 + _, err := fmt.Sscan(val, &floatVal) + if err != nil { + return fmt.Errorf("DotenvMarshaler: failed to parse float for field %s: %w", field.Name, err) + } + f.SetFloat(floatVal) + default: + return fmt.Errorf("DotenvMarshaler: unsupported field type %s for field %s", f.Kind(), field.Name) + } + } + } + } + + default: + return fmt.Errorf("DotenvMarshaler: unsupported type %s", rvElem.Kind()) + } + + return nil +} diff --git a/go.mod b/go.mod index 52233d8..e5e3fbc 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.20 require ( github.com/goccy/go-json v0.10.2 + github.com/joho/godotenv v1.5.1 github.com/pelletier/go-toml/v2 v2.0.7 github.com/stretchr/testify v1.9.0 gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index ff405fd..27b2d05 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= diff --git a/gophig_test.go b/gophig_test.go index 39fbb38..871b25d 100644 --- a/gophig_test.go +++ b/gophig_test.go @@ -1,18 +1,19 @@ package gophig_test import ( - "github.com/restartfu/gophig" - "github.com/stretchr/testify/require" "os" "testing" + + "github.com/restartfu/gophig" + "github.com/stretchr/testify/require" ) type MockMarshaler struct{} -func (m MockMarshaler) Marshal(interface{}) ([]byte, error) { +func (m MockMarshaler) Marshal(any) ([]byte, error) { return []byte{}, nil } -func (MockMarshaler) Unmarshal([]byte, interface{}) error { +func (MockMarshaler) Unmarshal([]byte, any) error { return nil } @@ -34,6 +35,7 @@ func TestGophig_GetConf(t *testing.T) { "json", "toml", "yaml", + "env", } { t.Run("sample unmarshals successfully into "+ext+" sample struct", func(t *testing.T) { marshaler, err := gophig.MarshalerFromExtension(ext) @@ -62,6 +64,7 @@ func TestGophig_SetConf(t *testing.T) { "json", "toml", "yaml", + "env", } { t.Run("sample marshals successfully into "+ext+" sample data", func(t *testing.T) { marshaler, err := gophig.MarshalerFromExtension(ext) diff --git a/marshal.go b/marshal.go index 925a633..f9d6b38 100644 --- a/marshal.go +++ b/marshal.go @@ -25,8 +25,8 @@ func IsUnsupportedExtensionErr(err error) bool { // Marshaler is an interface that can marshal and unmarshal data. type Marshaler interface { - Marshal(v interface{}) ([]byte, error) - Unmarshal(data []byte, v interface{}) error + Marshal(v any) ([]byte, error) + Unmarshal(data []byte, v any) error } // MarshalerFromExtension is a Marshaler that uses a file extension to determine which Marshaler to use. @@ -39,6 +39,8 @@ func MarshalerFromExtension(ext string) (Marshaler, error) { return JSONMarshaler{}, nil case "yaml": return YAMLMarshaler{}, nil + case "env": + return DotenvMarshaler{}, nil } return nil, UnsupportedExtensionError{ext} } diff --git a/tests/assets/sample.env b/tests/assets/sample.env new file mode 100644 index 0000000..50b1cf5 --- /dev/null +++ b/tests/assets/sample.env @@ -0,0 +1,3 @@ +NAME=jane +SURNAME=doe +AGE=20