diff options
| -rw-r--r-- | go/conf/conf.go | 273 |
1 files changed, 273 insertions, 0 deletions
diff --git a/go/conf/conf.go b/go/conf/conf.go new file mode 100644 index 0000000..631a327 --- /dev/null +++ b/go/conf/conf.go @@ -0,0 +1,273 @@ +// Read configuration values from files and environment variables. +// A configuration file has the following format: +// +// # a comment always starts at the beginning of a line +// key = value +// +// Keys must only contain alpha-numeric characters, dots (`.`), and +// underscores (`_`). All leading and trailing spaces from keys and +// values are trimmed. Configuration values can be of type bool, integer, +// float, string, or string slice with configurable delimiter. +// +// All configuration values are either read from a file or from the environment. +// An environment variable overwrites the configuration value of a file. The +// name of the environment variable is derived from the configuration key by +// replacing all dots with underscores, transforming everything to uppercase, and +// prepending an optional prefix. For example the environment variable for a key +// `foo.bar_baz` is `FOO_BAR_BAZ`. +// +// Package API: +// +// `Read` - read a configuration into a struct +// `FromFile` - option to define a configuration file +// `EnvPrefix` - option to define a prefix for environment variable +// `ListDelimiter` - option to define a delimiter for slice values +package conf + +import ( + "bytes" + "errors" + "os" + "reflect" + "strconv" + "strings" +) + +const configTag = "conf" + +// Read reads the configuration values specified by `config`. The given +// configuration type must be a struct where each field must define its key +// in a `conf` tag. +// +// A non-existing configuration file is equivalent to an empty file and does +// not return an error. If a value is neither defined as an environment variable +// nor in the configuration file, the struct field will not be touched. +func Read[T any](config *T, opts ...Option) error { + o := options{listDelim: ","} + for _, opt := range opts { + opt(&o) + } + + var kvs []kv + if o.filename != "" { + kvfile, err := os.ReadFile(o.filename) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + return err + } + } else { + kvs, err = parseKV(kvfile) + if err != nil { + return err + } + } + } + + structVal := reflect.ValueOf(config).Elem() + if structVal.Kind() != reflect.Struct { + panic("config type must be a struct") + } + return assignStructFields(structVal, environment(kvs, o.envPrefix), o) +} + +type options struct { + filename string + envPrefix string + listDelim string +} + +type Option func(*options) + +// FromFile specifies a file where configuration values are read from. +func FromFile(filename string) Option { + return func(o *options) { o.filename = filename } +} + +// EnvPrefix defines a prefix that will be preprended to the configuration key when +// consulting the environment. +func EnvPrefix(prefix string) Option { + return func(o *options) { o.envPrefix = prefix } +} + +// ListDelimiter defines the delimiter by which string slices are split. The given +// value must not be empty. +func ListDelimiter(delim string) Option { + if delim == "" { + panic("list delimiter must not be empty") + } + return func(o *options) { o.listDelim = delim } +} + +func assignStructFields(structVal reflect.Value, env env, opts options) error { + for _, field := range reflect.VisibleFields(structVal.Type()) { + fieldVal := structVal.FieldByIndex(field.Index) + switch fieldVal.Kind() { + case reflect.Struct: + err := assignStructFields(fieldVal, env, opts) + if err != nil { + return err + } + + case reflect.Slice: + key := keyOf(field) + if val, has := env.get(key); has { + elems := strings.Split(val, opts.listDelim) + sliceVals := reflect.MakeSlice(fieldVal.Type(), len(elems), len(elems)) + for i, elem := range elems { + err := assignScalarField(sliceVals.Index(i), key, elem) + if err != nil { + return err + } + } + fieldVal.Set(sliceVals) + } + + default: + key := keyOf(field) + if val, has := env.get(key); has { + err := assignScalarField(fieldVal, key, val) + if err != nil { + return err + } + } + } + } + return nil +} + +func assignScalarField(field reflect.Value, key string, val string) error { + errInvalid := func() error { + return errors.New("invalid config value " + key + ": '" + val + "'") + } + + switch field.Kind() { + case reflect.Bool: + switch strings.ToLower(val) { + case "yes", "true", "1": + field.SetBool(true) + case "no", "false", "0": + field.SetBool(false) + default: + return errInvalid() + } + + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + v, err := strconv.ParseInt(val, 10, field.Type().Bits()) + if err != nil { + return errInvalid() + } + field.SetInt(v) + + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + v, err := strconv.ParseUint(val, 10, field.Type().Bits()) + if err != nil { + return errInvalid() + } + field.SetUint(v) + + case reflect.Float32, reflect.Float64: + val, err := strconv.ParseFloat(val, field.Type().Bits()) + if err != nil { + return errInvalid() + } + field.SetFloat(val) + + case reflect.String: + v := val + if len(v) > 0 && (v[0] == '"' || v[0] == '\'') { + var err error + v, err = strconv.Unquote(val) + if err != nil { + return errInvalid() + } + } + field.SetString(v) + + default: + panic("unsupported field type " + field.Type().Name()) + } + + return nil +} + +type kv struct { + key string + val string +} + +func parseKV(content []byte) ([]kv, error) { + var kvs []kv + for ln := 1; len(content) > 0; ln++ { + eol := bytes.IndexByte(content, '\n') + if eol < 0 { + eol = len(content) - 1 + } + + line := bytes.TrimSpace(content[:eol+1]) + content = content[eol+1:] + if len(line) == 0 || line[0] == '#' { + continue + } + + eq := bytes.IndexByte(line, '=') + if eq < 0 { + return nil, errors.New("invalid config format (line " + strconv.Itoa(ln) + ")") + } + + kvs = append(kvs, kv{ + key: string(bytes.TrimSpace(line[:eq])), + val: string(bytes.TrimSpace(line[eq+1:])), + }) + } + return kvs, nil +} + +func keyOf(f reflect.StructField) string { + k := f.Tag.Get(configTag) + if k == "" { + panic("missing " + configTag + " for field " + f.Name) + } + for _, r := range k { + switch { + case 'a' <= r && r <= 'z': + case 'A' <= r && r <= 'Z': + case '0' <= r && r <= '9': + case r == '.' || r == '_': + default: + panic("unsupported config key " + k) + } + } + return k +} + +type env struct { + kvs []kv + envPrefix string +} + +func environment(kvs []kv, envPrefix string) env { + return env{ + kvs: kvs, + envPrefix: envPrefix, + } +} + +func (env env) get(key string) (string, bool) { + envkey := key + envkey = env.envPrefix + envkey + envkey = strings.ReplaceAll(envkey, ".", "_") + envkey = strings.ToUpper(envkey) + if val, has := os.LookupEnv(envkey); has { + return val, true + } + + // When a key exists twice, the second occurence should override + // the first one. So we need to search backwards here. + for i := len(env.kvs); i > 0; { + i-- + if kv := env.kvs[i]; kv.key == key { + return kv.val, true + } + } + return "", false +} |