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