A section tagged init:"hidden" isn't written to the file config init generates; fts_mock and sleeper are. config init --all writes them too. They still load, and the config commands read and set them as before.
180 lines
4.8 KiB
Go
180 lines
4.8 KiB
Go
package config
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"maps"
|
|
"reflect"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/spf13/viper"
|
|
"go.yaml.in/yaml/v3"
|
|
)
|
|
|
|
// EnvPrefix prefixes environment variables that override configuration keys,
|
|
// e.g. JOBSEARCH_LOGGING_LEVEL for logging.level.
|
|
const EnvPrefix string = "JOBSEARCH"
|
|
|
|
// EtcConfigPath is searched for config.* when no config file is given, before
|
|
// the working directory.
|
|
const EtcConfigPath string = "/etc/job-search"
|
|
|
|
// JobSearchConfig is the application's whole configuration, one field per
|
|
// section. Each field's cfg tag must match the section's JSCK* constant.
|
|
type JobSearchConfig struct {
|
|
Database JSCDatabase `cfg:"database"`
|
|
Logging JSCLogging `cfg:"logging"`
|
|
Server JSCServer `cfg:"server"`
|
|
Sources JSCSources `cfg:"sources"`
|
|
}
|
|
|
|
// LoadDefaultConfig sets Viper's defaults from the configuration's default
|
|
// struct tags.
|
|
func LoadDefaultConfig() {
|
|
var conf JobSearchConfig
|
|
if err := applyDefaults(&conf); err != nil {
|
|
panic(fmt.Sprintf("config: %v", err))
|
|
}
|
|
LoadToViper(conf)
|
|
}
|
|
|
|
// InitDefaults returns the default configuration as dotted keys, as
|
|
// Flatten gives it, for `config init` to write. Sections tagged
|
|
// init:"hidden" (the test sources) are left out unless includeHidden is set.
|
|
func InitDefaults(includeHidden bool) map[string]any {
|
|
var conf JobSearchConfig
|
|
if err := applyDefaults(&conf); err != nil {
|
|
panic(fmt.Sprintf("config: %v", err))
|
|
}
|
|
flat := conf.Flatten()
|
|
if includeHidden {
|
|
return flat
|
|
}
|
|
|
|
hidden := hiddenFromInitKeys("", reflect.TypeFor[JobSearchConfig]())
|
|
for _, section := range hidden {
|
|
for key := range flat {
|
|
if strings.HasPrefix(key, section+".") {
|
|
delete(flat, key)
|
|
}
|
|
}
|
|
}
|
|
return flat
|
|
}
|
|
|
|
// ---------- Section methods (see Section) ----------
|
|
|
|
// Map implements Section.
|
|
func (c JobSearchConfig) Map() map[string]any { return mapOf(c) }
|
|
|
|
// Flatten implements Section.
|
|
func (c JobSearchConfig) Flatten() map[string]any { return flattenOf("", c) }
|
|
|
|
// TypeOf implements Section.
|
|
func (c JobSearchConfig) TypeOf(key string) (reflect.Kind, error) {
|
|
return typeOf(c, key)
|
|
}
|
|
|
|
// ValueOf implements Section.
|
|
func (c JobSearchConfig) ValueOf(key string) (any, error) {
|
|
return valueOf(c, key)
|
|
}
|
|
|
|
// SetValue implements Section.
|
|
func (c *JobSearchConfig) SetValue(key string, value any) error {
|
|
return setValue(c, key, value)
|
|
}
|
|
|
|
// LoadFromViper copies every key Viper knows that conf also has into conf.
|
|
// Unknown keys are skipped; invalid values are logged and ignored.
|
|
func LoadFromViper(conf *JobSearchConfig) {
|
|
keys := viper.AllKeys()
|
|
for i := range keys {
|
|
key := keys[i]
|
|
keyType, _ := conf.TypeOf(key)
|
|
if keyType == reflect.Invalid {
|
|
continue
|
|
}
|
|
value, ok := viperValue(key, keyType)
|
|
if !ok {
|
|
slog.Error(
|
|
"unknown config key type",
|
|
"key", key,
|
|
"kind", keyType.String(),
|
|
)
|
|
continue
|
|
}
|
|
if err := conf.SetValue(key, value); err != nil {
|
|
slog.Warn("ignoring invalid config value", "key", key, "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// viperValue reads key from Viper as a value of kind. Its bool is false for a
|
|
// kind LoadFromViper doesn't handle.
|
|
func viperValue(key string, kind reflect.Kind) (any, bool) {
|
|
switch kind {
|
|
case reflect.String:
|
|
return viper.GetString(key), true
|
|
case reflect.Int:
|
|
return viper.GetInt(key), true
|
|
case reflect.Bool:
|
|
return viper.GetBool(key), true
|
|
case reflect.Slice:
|
|
// A string, e.g. from an environment variable, is passed on for
|
|
// SetValue to split on commas; GetStringSlice would split it on
|
|
// spaces. A list of objects (a list of sections) is passed on as it
|
|
// is, as GetStringSlice would flatten it.
|
|
switch value := viper.Get(key).(type) {
|
|
case string:
|
|
return value, true
|
|
case []map[string]any: // as LoadToViper sets it
|
|
return value, true
|
|
case []any:
|
|
if len(value) > 0 {
|
|
if _, ok := value[0].(map[string]any); ok {
|
|
return value, true
|
|
}
|
|
}
|
|
}
|
|
return viper.GetStringSlice(key), true
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// LoadToViper sets every key of conf as a Viper default.
|
|
func LoadToViper(conf JobSearchConfig) {
|
|
flatMap := conf.Flatten()
|
|
for key := range flatMap {
|
|
viper.SetDefault(key, flatMap[key])
|
|
}
|
|
}
|
|
|
|
// String formats the configuration one "key = value <type>" line per key, with
|
|
// dotted keys, sorted by key.
|
|
func (c JobSearchConfig) String() string {
|
|
var builder strings.Builder
|
|
flat := c.Flatten()
|
|
for _, key := range slices.Sorted(maps.Keys(flat)) {
|
|
kind, _ := c.TypeOf(key)
|
|
fmt.Fprint(&builder, key, " = ", flat[key], " <", kind, ">\n")
|
|
}
|
|
return builder.String()
|
|
}
|
|
|
|
// JSON formats the configuration as indented JSON.
|
|
func (c JobSearchConfig) JSON() string {
|
|
confMap := c.Map()
|
|
encoded, _ := json.MarshalIndent(confMap, "", " ")
|
|
return string(encoded)
|
|
}
|
|
|
|
// YAML formats the configuration as YAML.
|
|
func (c JobSearchConfig) YAML() string {
|
|
confMap := c.Map()
|
|
encoded, _ := yaml.Marshal(confMap)
|
|
return string(encoded)
|
|
}
|