From 25472a96d54ec28646a47aade50ddc1ffc24a6a1 Mon Sep 17 00:00:00 2001 From: kjuulh Date: Mon, 21 Jul 2025 16:35:27 +0200 Subject: [PATCH] feat: add basic config loader --- config.go | 108 +++++++++++++++++++++++++++++++ config_test.go | 168 +++++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 14 +++++ go.sum | 12 ++++ 4 files changed, 302 insertions(+) create mode 100644 config.go create mode 100644 config_test.go create mode 100644 go.mod create mode 100644 go.sum diff --git a/config.go b/config.go new file mode 100644 index 0000000..896e11b --- /dev/null +++ b/config.go @@ -0,0 +1,108 @@ +package yourconfig + +import ( + "errors" + "fmt" + "os" + "reflect" + "strconv" + "strings" + + "github.com/ettle/strcase" +) + +func MustLoad[T any]() T { + output, err := Load[T]() + if err != nil { + panic(fmt.Sprintf("must load: %s", err.Error())) + } + + return output +} + +func Load[T any]() (T, error) { + var cfg T + + v := reflect.ValueOf(&cfg).Elem() + t := v.Type() + + errs := make([]error, 0) + +OUTER: + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + tagStr := field.Tag.Get("conf") + if tagStr == "" { + continue + } + + singleValue := make([]string, 0) + options := make(map[string]string, 0) + + values := strings.SplitSeq(tagStr, ",") + for value := range values { + if value == "" { + continue + } + + key, val, ok := strings.Cut(strings.TrimSpace(value), ":") + if ok { + options[key] = val + } else { + singleValue = append(singleValue, value) + } + } + + var tag tag + if len(singleValue) == 0 { + tag.Env = strcase.ToSNAKE(field.Name) + } else { + tag.Env = singleValue[0] // We always count the first value as the name, if set + } + + for _, option := range singleValue { + switch option { + case "required": + tag.Required = true + } + } + + for key, val := range options { + switch key { + case "required": + required, err := strconv.ParseBool(val) + if err != nil { + errs = append(errs, fmt.Errorf("field: %s (key: %s), err: %w", field.Name, key, err)) + continue OUTER + } + + tag.Required = required + } + } + + valueStr := os.Getenv(tag.Env) + if valueStr == "" && tag.Required { + errs = append(errs, fmt.Errorf("field: %s (env=%s) is not set and is required", field.Name, tag.Env)) + continue OUTER + } + + fieldValue := v.Field(i) + if !fieldValue.CanSet() { + errs = append(errs, fmt.Errorf("field: %s is not settable", field.Name)) + continue OUTER + } + + fieldValue.SetString(valueStr) + } + + if err := errors.Join(errs...); err != nil { + return cfg, fmt.Errorf("config failed: %w", err) + } + + return cfg, nil +} + +type tag struct { + Env string + Required bool +} diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..0368b16 --- /dev/null +++ b/config_test.go @@ -0,0 +1,168 @@ +package yourconfig_test + +import ( + "testing" + + "github.com/kjuulh/yourconfig" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoad(t *testing.T) { + t.Run("no types", func(t *testing.T) { + type Config struct { + SomeItem string + someOtherItem string + someBool bool + } + + val, err := yourconfig.Load[Config]() + require.NoError(t, err) + + assert.Zero(t, val) + }) + + t.Run("default tag, nothing set, no env set", func(t *testing.T) { + type Config struct { + SomeItem string `conf:""` + someOtherItem string + someBool bool + } + + val, err := yourconfig.Load[Config]() + require.NoError(t, err) + + assert.Zero(t, val) + }) + + t.Run("default tag (required=true), nothing set, no env set, err", func(t *testing.T) { + type Config struct { + SomeItem string `conf:"required:true"` + someOtherItem string + someBool bool + } + + val, err := yourconfig.Load[Config]() + require.Error(t, err) + require.Zero(t, val) + + assert.Equal(t, "config failed: field: SomeItem (env=SOME_ITEM) is not set and is required", err.Error()) + }) + + t.Run("default tag (required=false), nothing set, no env set no error", func(t *testing.T) { + type Config struct { + SomeItem string `conf:"required:false"` + someOtherItem string + someBool bool + } + + val, err := yourconfig.Load[Config]() + require.NoError(t, err) + + assert.Zero(t, val) + }) + + t.Run("env tag nothing set, no env set, no error", func(t *testing.T) { + type Config struct { + SomeItem string `conf:"SOME_ITEM"` + someOtherItem string + someBool bool + } + + val, err := yourconfig.Load[Config]() + require.NoError(t, err) + + assert.Zero(t, val) + }) + + t.Run("default tag (required=true), nothing set, no env set, err", func(t *testing.T) { + type Config struct { + SomeItem string `conf:"SOME_ITEM,required:true"` + someOtherItem string + someBool bool + } + + val, err := yourconfig.Load[Config]() + require.Error(t, err) + require.Zero(t, val) + + assert.Equal(t, "config failed: field: SomeItem (env=SOME_ITEM) is not set and is required", err.Error()) + }) + + t.Run("default tag (required), nothing set, no env set, err", func(t *testing.T) { + type Config struct { + SomeItem string `conf:"SOME_ITEM,required"` + someOtherItem string + someBool bool + } + + val, err := yourconfig.Load[Config]() + require.Error(t, err) + require.Zero(t, val) + + assert.Equal(t, "config failed: field: SomeItem (env=SOME_ITEM) is not set and is required", err.Error()) + }) + + t.Run("default tag private, trying to set, err", func(t *testing.T) { + type Config struct { + SomeItem string + someOtherItem string `conf:"required:true"` + someBool bool + } + + t.Setenv("SOME_OTHER_ITEM", "unsettable") + + val, err := yourconfig.Load[Config]() + require.Error(t, err) + require.Zero(t, val) + + assert.Equal(t, "config failed: field: someOtherItem is not settable", err.Error()) + }) + + t.Run("env tag and env set, no error", func(t *testing.T) { + type Config struct { + SomeItem string `conf:"required:true"` + someOtherItem string + someBool bool + } + + t.Setenv("SOME_ITEM", "some-item") + + val, err := yourconfig.Load[Config]() + require.NoError(t, err) + + assert.Equal(t, "some-item", val.SomeItem) + }) + + t.Run("env tag (different name) and env set, no error", func(t *testing.T) { + type Config struct { + SomeItem string `conf:"DIFFERENT_NAME,required:true"` + someOtherItem string + someBool bool + } + + t.Setenv("DIFFERENT_NAME", "some-item") + + val, err := yourconfig.Load[Config]() + require.NoError(t, err) + + assert.Equal(t, "some-item", val.SomeItem) + }) + + t.Run("multiple env tag and env set, no error", func(t *testing.T) { + type Config struct { + SomeItem string `conf:"required:true"` + SomeOtherItem string `conf:"required:true"` + someBool bool + } + + t.Setenv("SOME_ITEM", "some-item") + t.Setenv("SOME_OTHER_ITEM", "some-other-item") + + val, err := yourconfig.Load[Config]() + require.NoError(t, err) + + assert.Equal(t, "some-item", val.SomeItem) + assert.Equal(t, "some-other-item", val.SomeOtherItem) + }) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6eaaa43 --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module github.com/kjuulh/yourconfig + +go 1.24.5 + +require ( + github.com/ettle/strcase v0.2.0 + github.com/stretchr/testify v1.10.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..cf3702e --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +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/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q= +github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=