knadh / koanf

Simple, extremely lightweight, extensible, configuration management library for Go. Support for JSON, TOML, YAML, env, command line, file, S3 etc. Alternative to viper.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Default values from Tags for missing config values

tburschka opened this issue · comments

It's not a bug in koanf, but i need help to find the right way to solve the issue.

I've created this repository with a minimal example:

https://github.com/tburschka/koanf-default-value

The idea is to define configuration values only "partially" and fill up the residual values via default tag.
Therefore i was using github.com/creasty/defaults for this, but my issue is that there are no additional information that the fields were set and aren't the initial values (e.g. false for a boolean).

My current approach is to create a custom provider which contains the current koanf (or confMap) to check against another confmap which holds only the default values from the target struct. (Not knowing how to create such a config map that holds the same structure).
Another approach which come to my mind while writing this is a custom DecodeHookFunc.
I'm a little bit lost and would be thankful for an idea how to solve this.

Edit:

An example can be found in the test:

https://github.com/tburschka/koanf-default-value/blob/main/config_test.go

I'm not sure if it fully solves your requirement of using struct tags, but maybe you can utilize the structs package to load the default config.

Ref:
https://github.com/knadh/koanf/blob/master/README.md?plain=1#L516-L538

So, how it can look like is this:

// hardcode it
var DefaultConfig MyConfig = DefaultConfig{
...
}

// or 
func initDefaults() MyConfig {
	myConf := MyConfig{}
	if err := defaults.Set(&obj); err != nil {
		...
	}
        return myConf
}

func InitConfig(fpath string) MyConfig {
    // Load defaults from hardcoded struct.
    k.Load(structs.Provider(DefaultConfig))

    // or
    // use the defaults package
    k.Load(structs.Provider(initDefaults()))


    // Override from file.
    k.Load(toml.Provider(fpath))

    // Override config struct with cli flags
    k.Load(pflag.Provider())
}

I finally solved this. First approach was a custom provider, but i swapped to a custom decode hook:

https://github.com/tburschka/koanf-default-value/blob/main/decodehooks/defaults/defaults.go

package defaults

import (
	"reflect"
	"strings"

	"github.com/go-viper/mapstructure/v2"
)

func Defaults(keyTag string, defaultTag string) mapstructure.DecodeHookFunc {
	return func(f reflect.Value, t reflect.Value) (interface{}, error) {
		tType := t.Type()

		if tType.Kind() == reflect.Struct {
			for i := 0; i < tType.NumField(); i++ {
				setDefault(f, tType.Field(i), keyTag, defaultTag)
			}
		}

		return f.Interface(), nil
	}
}

func setDefault(f reflect.Value, t reflect.StructField, keyTag string, defaultTag string) {
	if f.Kind() == reflect.Map {
		key, _, _ := strings.Cut(t.Tag.Get(keyTag), ",")
		val, ok := t.Tag.Lookup(defaultTag)
		if !ok {
			return
		}

		// check for key in the map
		for _, e := range f.MapKeys() {
			// key found, already set, nothing to do
			if key == e.String() {
				return
			}
		}

		fVal := reflect.ValueOf(val)

		// special handling for empty/missing structs
		tType := t.Type
		if tType.Kind() == reflect.Struct {
			// create an empty map
			fVal = reflect.ValueOf(map[string]any{})
			for i := 0; i < tType.NumField(); i++ {
				setDefault(fVal, tType.Field(i), keyTag, defaultTag)
			}
		}

		// add missing key with default to the map
		f.SetMapIndex(reflect.ValueOf(key), fVal)
	}
}

it iterates over the structs and set the defaults. Note that this decodehook should be the first in the order:

	config := &Config{}
	_ = k.UnmarshalWithConf("", config, koanf.UnmarshalConf{
		Tag: "yaml",
		DecoderConfig: &mapstructure.DecoderConfig{
			DecodeHook: mapstructure.ComposeDecodeHookFunc(
				defaults.Defaults("yaml", "default"),
				mapstructure.StringToTimeDurationHookFunc(),
				stringtoslice.StringToSlice(),
				stringtomap.StringToMap(),
			),
			Metadata:         nil,
			Result:           config,
			WeaklyTypedInput: true,
		},
	})

Also, it's important, that you set the defaults for slices [] and structs {} if you want the decode hook to handle them.

Finally, since i use two sources, the default merge approach is not working, so i needed a custom merge func.
I use mergo with the WithSliceDeepCopy option.

import 	"dario.cat/mergo"


// [...]
	_ = k.Load(file.Provider(name), yaml.Parser())
   _ = k.Load(env2json.Provider(configEnvPrefix, configEnvDelim, func(s string) string {
   	return strings.ToLower(strings.TrimPrefix(s, configEnvPrefix+configEnvDelim))
   }), json.Parser(), koanf.WithMergeFunc(func(src, dest map[string]interface{}) error {
   	return mergo.Merge(&dest, src, mergo.WithSliceDeepCopy)
   }))

I've created tests for all these cases (https://github.com/tburschka/koanf-default-value/blob/main/config_test.go). It works like a charm. I keep it online so in case anyone needs this, he can use it...