feat: add basic config loader

This commit is contained in:
2025-07-21 16:35:27 +02:00
commit 25472a96d5
4 changed files with 302 additions and 0 deletions

108
config.go Normal file
View File

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

168
config_test.go Normal file
View File

@@ -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)
})
}

14
go.mod Normal file
View File

@@ -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
)

12
go.sum Normal file
View File

@@ -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=