Compare commits
3 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59b968b1ef | ||
| 9a12aa8d7d | |||
|
|
62c57e6eef |
9 changed files with 116 additions and 432 deletions
|
|
@ -7,10 +7,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||||
This project uses the Core CLI (`core` binary), not `go` directly.
|
This project uses the Core CLI (`core` binary), not `go` directly.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
core go test # run all tests
|
go test ./... # run all tests
|
||||||
core go test --run TestConfig_Get_Good # run a single test
|
go test -run TestConfig_Get_Good ./... # run a single test
|
||||||
core go cov # test with coverage
|
go test -cover ./... # test with coverage
|
||||||
core go cov --open # coverage + open HTML report
|
|
||||||
|
|
||||||
core go qa # format, vet, lint, test
|
core go qa # format, vet, lint, test
|
||||||
core go qa full # adds race detector, vuln scan, security audit
|
core go qa full # adds race detector, vuln scan, security audit
|
||||||
|
|
@ -34,7 +33,7 @@ This prevents environment variables from leaking into saved config files. When i
|
||||||
|
|
||||||
**Service wrapper**: `Service` in `service.go` wraps `Config` with framework lifecycle (`core.Startable`). Both `Config` and `Service` satisfy `core.Config`, enforced by compile-time assertions.
|
**Service wrapper**: `Service` in `service.go` wraps `Config` with framework lifecycle (`core.Startable`). Both `Config` and `Service` satisfy `core.Config`, enforced by compile-time assertions.
|
||||||
|
|
||||||
**Storage abstraction**: All file I/O goes through `coreio.Medium` (from `go-io`). Tests use `coreio.NewMockMedium()` with an in-memory `Files` map — never touch the real filesystem.
|
**Storage abstraction**: All file I/O goes through `io.Medium` (from `go-io`). Tests use `io.NewMockMedium()` with an in-memory `Files` map — never touch the real filesystem.
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
|
|
|
||||||
170
config.go
170
config.go
|
|
@ -1,6 +1,6 @@
|
||||||
// Package config provides layered configuration management for the Core framework.
|
// Package config provides layered configuration management for the Core framework.
|
||||||
//
|
//
|
||||||
// Configuration values are resolved in priority order: defaults -> file -> env -> Set().
|
// Configuration values are resolved in priority order: defaults -> file -> env -> flags.
|
||||||
// Values are stored in a YAML file at ~/.core/config.yaml by default.
|
// Values are stored in a YAML file at ~/.core/config.yaml by default.
|
||||||
//
|
//
|
||||||
// Keys use dot notation for nested access:
|
// Keys use dot notation for nested access:
|
||||||
|
|
@ -11,17 +11,12 @@
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"iter"
|
"iter"
|
||||||
"os"
|
"maps"
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
coreio "forge.lthn.ai/core/go-io"
|
core "dappco.re/go/core"
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
coreio "dappco.re/go/core/io"
|
||||||
core "forge.lthn.ai/core/go/pkg/core"
|
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
@ -30,8 +25,8 @@ import (
|
||||||
// It uses viper as the underlying configuration engine.
|
// It uses viper as the underlying configuration engine.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
full *viper.Viper // Full configuration (file + env + defaults)
|
v *viper.Viper // Full configuration (file + env + defaults)
|
||||||
file *viper.Viper // File-backed configuration only (for persistence)
|
f *viper.Viper // File-backed configuration only (for persistence)
|
||||||
medium coreio.Medium
|
medium coreio.Medium
|
||||||
path string
|
path string
|
||||||
}
|
}
|
||||||
|
|
@ -56,22 +51,27 @@ func WithPath(path string) Option {
|
||||||
// WithEnvPrefix sets the prefix for environment variables.
|
// WithEnvPrefix sets the prefix for environment variables.
|
||||||
func WithEnvPrefix(prefix string) Option {
|
func WithEnvPrefix(prefix string) Option {
|
||||||
return func(c *Config) {
|
return func(c *Config) {
|
||||||
c.full.SetEnvPrefix(strings.TrimSuffix(prefix, "_"))
|
c.v.SetEnvPrefix(prefix)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dotReplacer implements viper.StringReplacer, converting dots to underscores
|
||||||
|
// for environment variable key mapping without importing strings directly.
|
||||||
|
type dotReplacer struct{}
|
||||||
|
|
||||||
|
func (dotReplacer) Replace(s string) string { return core.Replace(s, ".", "_") }
|
||||||
|
|
||||||
// New creates a new Config instance with the given options.
|
// New creates a new Config instance with the given options.
|
||||||
// If no medium is provided, it defaults to io.Local.
|
// If no medium is provided, it defaults to io.Local.
|
||||||
// If no path is provided, it defaults to ~/.core/config.yaml.
|
// If no path is provided, it defaults to ~/.core/config.yaml.
|
||||||
func New(opts ...Option) (*Config, error) {
|
func New(opts ...Option) (*Config, error) {
|
||||||
c := &Config{
|
c := &Config{
|
||||||
full: viper.New(),
|
v: viper.NewWithOptions(viper.EnvKeyReplacer(dotReplacer{})),
|
||||||
file: viper.New(),
|
f: viper.New(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure viper defaults
|
// Configure viper defaults
|
||||||
c.full.SetEnvPrefix("CORE_CONFIG")
|
c.v.SetEnvPrefix("CORE_CONFIG")
|
||||||
c.full.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
|
||||||
|
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
opt(c)
|
opt(c)
|
||||||
|
|
@ -82,79 +82,54 @@ func New(opts ...Option) (*Config, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.path == "" {
|
if c.path == "" {
|
||||||
home, err := os.UserHomeDir()
|
home := core.Env("DIR_HOME")
|
||||||
if err != nil {
|
if home == "" {
|
||||||
return nil, coreerr.E("config.New", "failed to determine home directory", err)
|
return nil, core.E("config.New", "failed to determine home directory", nil)
|
||||||
}
|
}
|
||||||
c.path = filepath.Join(home, ".core", "config.yaml")
|
c.path = core.Path(home, ".core", "config.yaml")
|
||||||
}
|
}
|
||||||
|
|
||||||
c.full.AutomaticEnv()
|
c.v.AutomaticEnv()
|
||||||
|
|
||||||
// Load existing config file if it exists
|
// Load existing config file if it exists
|
||||||
if c.medium.Exists(c.path) {
|
if c.medium.Exists(c.path) {
|
||||||
if err := c.LoadFile(c.medium, c.path); err != nil {
|
if err := c.LoadFile(c.medium, c.path); err != nil {
|
||||||
return nil, coreerr.E("config.New", "failed to load config file", err)
|
return nil, core.E("config.New", "failed to load config file", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func configTypeForPath(path string) (string, error) {
|
|
||||||
ext := strings.ToLower(filepath.Ext(path))
|
|
||||||
if ext == "" && filepath.Base(path) == ".env" {
|
|
||||||
return "env", nil
|
|
||||||
}
|
|
||||||
if ext == "" {
|
|
||||||
return "yaml", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
switch ext {
|
|
||||||
case ".yaml", ".yml":
|
|
||||||
return "yaml", nil
|
|
||||||
case ".json":
|
|
||||||
return "json", nil
|
|
||||||
case ".toml":
|
|
||||||
return "toml", nil
|
|
||||||
case ".env":
|
|
||||||
return "env", nil
|
|
||||||
default:
|
|
||||||
return "", coreerr.E("config.configTypeForPath", "unsupported config file type: "+path, nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadFile reads a configuration file from the given medium and path and merges it into the current config.
|
// LoadFile reads a configuration file from the given medium and path and merges it into the current config.
|
||||||
// It supports YAML, JSON, TOML, and dotenv files (.env).
|
// It supports YAML and environment files (.env).
|
||||||
func (c *Config) LoadFile(m coreio.Medium, path string) error {
|
func (c *Config) LoadFile(m coreio.Medium, path string) error {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
configType, err := configTypeForPath(path)
|
|
||||||
if err != nil {
|
|
||||||
return coreerr.E("config.LoadFile", "failed to determine config file type: "+path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
content, err := m.Read(path)
|
content, err := m.Read(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return coreerr.E("config.LoadFile", fmt.Sprintf("failed to read config file: %s", path), err)
|
return core.E("config.LoadFile", "failed to read config file: "+path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
parsed := viper.New()
|
ext := core.PathExt(path)
|
||||||
parsed.SetConfigType(configType)
|
configType := "yaml"
|
||||||
if err := parsed.MergeConfig(strings.NewReader(content)); err != nil {
|
if ext == "" && core.PathBase(path) == ".env" {
|
||||||
return coreerr.E("config.LoadFile", fmt.Sprintf("failed to parse config file: %s", path), err)
|
configType = "env"
|
||||||
|
} else if ext != "" {
|
||||||
|
configType = core.TrimPrefix(ext, ".")
|
||||||
}
|
}
|
||||||
|
|
||||||
settings := parsed.AllSettings()
|
// Load into file-backed viper
|
||||||
|
c.f.SetConfigType(configType)
|
||||||
// Keep the persisted and runtime views aligned with the same parsed data.
|
if err := c.f.MergeConfig(core.NewReader(content)); err != nil {
|
||||||
if err := c.file.MergeConfigMap(settings); err != nil {
|
return core.E("config.LoadFile", "failed to parse config file (f): "+path, err)
|
||||||
return coreerr.E("config.LoadFile", "failed to merge config into file settings", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.full.MergeConfigMap(settings); err != nil {
|
// Load into full viper
|
||||||
return coreerr.E("config.LoadFile", "failed to merge config into full settings", err)
|
c.v.SetConfigType(configType)
|
||||||
|
if err := c.v.MergeConfig(core.NewReader(content)); err != nil {
|
||||||
|
return core.E("config.LoadFile", "failed to parse config file (v): "+path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -168,18 +143,18 @@ func (c *Config) Get(key string, out any) error {
|
||||||
defer c.mu.RUnlock()
|
defer c.mu.RUnlock()
|
||||||
|
|
||||||
if key == "" {
|
if key == "" {
|
||||||
if err := c.full.Unmarshal(out); err != nil {
|
if err := c.v.Unmarshal(out); err != nil {
|
||||||
return coreerr.E("config.Get", "failed to unmarshal full config", err)
|
return core.E("config.Get", "failed to unmarshal full config", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if !c.full.IsSet(key) {
|
if !c.v.IsSet(key) {
|
||||||
return coreerr.E("config.Get", fmt.Sprintf("key not found: %s", key), nil)
|
return core.E("config.Get", "key not found: "+key, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.full.UnmarshalKey(key, out); err != nil {
|
if err := c.v.UnmarshalKey(key, out); err != nil {
|
||||||
return coreerr.E("config.Get", fmt.Sprintf("failed to unmarshal key: %s", key), err)
|
return core.E("config.Get", "failed to unmarshal key: "+key, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -190,8 +165,8 @@ func (c *Config) Set(key string, v any) error {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
c.file.Set(key, v)
|
c.f.Set(key, v)
|
||||||
c.full.Set(key, v)
|
c.v.Set(key, v)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -202,32 +177,17 @@ func (c *Config) Commit() error {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
if err := Save(c.medium, c.path, c.file.AllSettings()); err != nil {
|
if err := Save(c.medium, c.path, c.f.AllSettings()); err != nil {
|
||||||
return coreerr.E("config.Commit", "failed to save config", err)
|
return core.E("config.Commit", "failed to save config", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// All returns an iterator over all configuration values in lexical key order
|
// All returns an iterator over all configuration values (including environment variables).
|
||||||
// (including environment variables).
|
|
||||||
func (c *Config) All() iter.Seq2[string, any] {
|
func (c *Config) All() iter.Seq2[string, any] {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
defer c.mu.RUnlock()
|
defer c.mu.RUnlock()
|
||||||
|
return maps.All(c.v.AllSettings())
|
||||||
settings := c.full.AllSettings()
|
|
||||||
keys := make([]string, 0, len(settings))
|
|
||||||
for key := range settings {
|
|
||||||
keys = append(keys, key)
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
|
|
||||||
return func(yield func(string, any) bool) {
|
|
||||||
for _, key := range keys {
|
|
||||||
if !yield(key, settings[key]) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path returns the path to the configuration file.
|
// Path returns the path to the configuration file.
|
||||||
|
|
@ -239,22 +199,15 @@ func (c *Config) Path() string {
|
||||||
// Returns the parsed data as a map, or an error if the file cannot be read or parsed.
|
// Returns the parsed data as a map, or an error if the file cannot be read or parsed.
|
||||||
// Deprecated: Use Config.LoadFile instead.
|
// Deprecated: Use Config.LoadFile instead.
|
||||||
func Load(m coreio.Medium, path string) (map[string]any, error) {
|
func Load(m coreio.Medium, path string) (map[string]any, error) {
|
||||||
switch ext := strings.ToLower(filepath.Ext(path)); ext {
|
|
||||||
case "", ".yaml", ".yml":
|
|
||||||
// These paths are safe to treat as YAML sources.
|
|
||||||
default:
|
|
||||||
return nil, coreerr.E("config.Load", "unsupported config file type: "+path, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
content, err := m.Read(path)
|
content, err := m.Read(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, coreerr.E("config.Load", "failed to read config file: "+path, err)
|
return nil, core.E("config.Load", "failed to read config file: "+path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
v := viper.New()
|
v := viper.New()
|
||||||
v.SetConfigType("yaml")
|
v.SetConfigType("yaml")
|
||||||
if err := v.ReadConfig(strings.NewReader(content)); err != nil {
|
if err := v.ReadConfig(core.NewReader(content)); err != nil {
|
||||||
return nil, coreerr.E("config.Load", "failed to parse config file: "+path, err)
|
return nil, core.E("config.Load", "failed to parse config file: "+path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return v.AllSettings(), nil
|
return v.AllSettings(), nil
|
||||||
|
|
@ -263,29 +216,20 @@ func Load(m coreio.Medium, path string) (map[string]any, error) {
|
||||||
// Save writes configuration data to a YAML file at the given path.
|
// Save writes configuration data to a YAML file at the given path.
|
||||||
// It ensures the parent directory exists before writing.
|
// It ensures the parent directory exists before writing.
|
||||||
func Save(m coreio.Medium, path string, data map[string]any) error {
|
func Save(m coreio.Medium, path string, data map[string]any) error {
|
||||||
switch ext := strings.ToLower(filepath.Ext(path)); ext {
|
|
||||||
case "", ".yaml", ".yml":
|
|
||||||
// These paths are safe to treat as YAML destinations.
|
|
||||||
default:
|
|
||||||
return coreerr.E("config.Save", "unsupported config file type: "+path, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
out, err := yaml.Marshal(data)
|
out, err := yaml.Marshal(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return coreerr.E("config.Save", "failed to marshal config", err)
|
return core.E("config.Save", "failed to marshal config", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
dir := filepath.Dir(path)
|
dir := core.PathDir(path)
|
||||||
if err := m.EnsureDir(dir); err != nil {
|
if err := m.EnsureDir(dir); err != nil {
|
||||||
return coreerr.E("config.Save", "failed to create config directory: "+dir, err)
|
return core.E("config.Save", "failed to create config directory: "+dir, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.Write(path, string(out)); err != nil {
|
if err := m.Write(path, string(out)); err != nil {
|
||||||
return coreerr.E("config.Save", "failed to write config file: "+path, err)
|
return core.E("config.Save", "failed to write config file: "+path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure Config implements core.Config at compile time.
|
|
||||||
var _ core.Config = (*Config)(nil)
|
|
||||||
|
|
|
||||||
231
config_test.go
231
config_test.go
|
|
@ -1,14 +1,11 @@
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"maps"
|
"maps"
|
||||||
"os"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
coreio "forge.lthn.ai/core/go-io"
|
core "dappco.re/go/core"
|
||||||
core "forge.lthn.ai/core/go/pkg/core"
|
coreio "dappco.re/go/core/io"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -92,23 +89,6 @@ func TestConfig_All_Good(t *testing.T) {
|
||||||
assert.Equal(t, "val2", all["key2"])
|
assert.Equal(t, "val2", all["key2"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConfig_All_Order_Good(t *testing.T) {
|
|
||||||
m := coreio.NewMockMedium()
|
|
||||||
|
|
||||||
cfg, err := New(WithMedium(m), WithPath("/tmp/test/config.yaml"))
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
_ = cfg.Set("zulu", "last")
|
|
||||||
_ = cfg.Set("alpha", "first")
|
|
||||||
|
|
||||||
var keys []string
|
|
||||||
for key, _ := range cfg.All() {
|
|
||||||
keys = append(keys, key)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, []string{"alpha", "zulu"}, keys)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConfig_Path_Good(t *testing.T) {
|
func TestConfig_Path_Good(t *testing.T) {
|
||||||
m := coreio.NewMockMedium()
|
m := coreio.NewMockMedium()
|
||||||
|
|
||||||
|
|
@ -205,7 +185,7 @@ func TestConfig_DefaultPath_Good(t *testing.T) {
|
||||||
cfg, err := New(WithMedium(m))
|
cfg, err := New(WithMedium(m))
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
home, _ := os.UserHomeDir()
|
home := core.Env("DIR_HOME")
|
||||||
assert.Equal(t, home+"/.core/config.yaml", cfg.Path())
|
assert.Equal(t, home+"/.core/config.yaml", cfg.Path())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -218,21 +198,6 @@ func TestLoadEnv_Good(t *testing.T) {
|
||||||
assert.Equal(t, "value", result["simple"])
|
assert.Equal(t, "value", result["simple"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadEnv_PrefixNormalisation_Good(t *testing.T) {
|
|
||||||
t.Setenv("MYAPP_SETTING", "secret")
|
|
||||||
t.Setenv("MYAPP_ALPHA", "first")
|
|
||||||
|
|
||||||
keys := make([]string, 0, 2)
|
|
||||||
values := make([]string, 0, 2)
|
|
||||||
for key, value := range Env("MYAPP") {
|
|
||||||
keys = append(keys, key)
|
|
||||||
values = append(values, value.(string))
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, []string{"alpha", "setting"}, keys)
|
|
||||||
assert.Equal(t, []string{"first", "secret"}, values)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoad_Bad(t *testing.T) {
|
func TestLoad_Bad(t *testing.T) {
|
||||||
m := coreio.NewMockMedium()
|
m := coreio.NewMockMedium()
|
||||||
|
|
||||||
|
|
@ -241,15 +206,6 @@ func TestLoad_Bad(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "failed to read config file")
|
assert.Contains(t, err.Error(), "failed to read config file")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoad_UnsupportedPath_Bad(t *testing.T) {
|
|
||||||
m := coreio.NewMockMedium()
|
|
||||||
m.Files["/tmp/test/config.json"] = `{"app":{"name":"core"}}`
|
|
||||||
|
|
||||||
_, err := Load(m, "/tmp/test/config.json")
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "unsupported config file type")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoad_InvalidYAML_Bad(t *testing.T) {
|
func TestLoad_InvalidYAML_Bad(t *testing.T) {
|
||||||
m := coreio.NewMockMedium()
|
m := coreio.NewMockMedium()
|
||||||
m.Files["/tmp/test/config.yaml"] = "invalid: yaml: content: [[[["
|
m.Files["/tmp/test/config.yaml"] = "invalid: yaml: content: [[[["
|
||||||
|
|
@ -259,68 +215,6 @@ func TestLoad_InvalidYAML_Bad(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "failed to parse config file")
|
assert.Contains(t, err.Error(), "failed to parse config file")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConfig_LoadFile_JSON_Good(t *testing.T) {
|
|
||||||
m := coreio.NewMockMedium()
|
|
||||||
m.Files["/tmp/test/config.json"] = `{"app":{"name":"core"}}`
|
|
||||||
|
|
||||||
cfg, err := New(WithMedium(m), WithPath("/tmp/test/config.json"))
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
var name string
|
|
||||||
err = cfg.Get("app.name", &name)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, "core", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConfig_LoadFile_Extensionless_Good(t *testing.T) {
|
|
||||||
m := coreio.NewMockMedium()
|
|
||||||
m.Files["/tmp/test/config"] = "app:\n name: core\n"
|
|
||||||
|
|
||||||
cfg, err := New(WithMedium(m), WithPath("/tmp/test/config"))
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
var name string
|
|
||||||
err = cfg.Get("app.name", &name)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, "core", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConfig_LoadFile_TOML_Good(t *testing.T) {
|
|
||||||
m := coreio.NewMockMedium()
|
|
||||||
m.Files["/tmp/test/config.toml"] = "app = { name = \"core\" }\n"
|
|
||||||
|
|
||||||
cfg, err := New(WithMedium(m), WithPath("/tmp/test/config.toml"))
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
var name string
|
|
||||||
err = cfg.Get("app.name", &name)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, "core", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConfig_LoadFile_Unsupported_Bad(t *testing.T) {
|
|
||||||
m := coreio.NewMockMedium()
|
|
||||||
|
|
||||||
cfg, err := New(WithMedium(m), WithPath("/tmp/test/config.txt"))
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
m.Files["/tmp/test/config.txt"] = "app.name=core"
|
|
||||||
err = cfg.LoadFile(m, "/tmp/test/config.txt")
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "unsupported config file type")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConfig_LoadFile_Unsupported_NoRead_Bad(t *testing.T) {
|
|
||||||
m := coreio.NewMockMedium()
|
|
||||||
|
|
||||||
cfg, err := New(WithMedium(m), WithPath("/tmp/test/config.txt"))
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
err = cfg.LoadFile(m, "/tmp/test/config.txt")
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "unsupported config file type")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSave_Good(t *testing.T) {
|
func TestSave_Good(t *testing.T) {
|
||||||
m := coreio.NewMockMedium()
|
m := coreio.NewMockMedium()
|
||||||
|
|
||||||
|
|
@ -336,39 +230,6 @@ func TestSave_Good(t *testing.T) {
|
||||||
assert.Contains(t, content, "key: value")
|
assert.Contains(t, content, "key: value")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSave_Extensionless_Good(t *testing.T) {
|
|
||||||
m := coreio.NewMockMedium()
|
|
||||||
|
|
||||||
err := Save(m, "/tmp/test/config", map[string]any{"key": "value"})
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
content, readErr := m.Read("/tmp/test/config")
|
|
||||||
assert.NoError(t, readErr)
|
|
||||||
assert.Contains(t, content, "key: value")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSave_UnsupportedPath_Bad(t *testing.T) {
|
|
||||||
m := coreio.NewMockMedium()
|
|
||||||
|
|
||||||
err := Save(m, "/tmp/test/config.json", map[string]any{"key": "value"})
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "unsupported config file type")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConfig_Commit_UnsupportedPath_Bad(t *testing.T) {
|
|
||||||
m := coreio.NewMockMedium()
|
|
||||||
|
|
||||||
cfg, err := New(WithMedium(m), WithPath("/tmp/test/config.json"))
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
err = cfg.Set("key", "value")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
err = cfg.Commit()
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "unsupported config file type")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConfig_LoadFile_Env(t *testing.T) {
|
func TestConfig_LoadFile_Env(t *testing.T) {
|
||||||
m := coreio.NewMockMedium()
|
m := coreio.NewMockMedium()
|
||||||
m.Files["/.env"] = "FOO=bar\nBAZ=qux"
|
m.Files["/.env"] = "FOO=bar\nBAZ=qux"
|
||||||
|
|
@ -398,39 +259,6 @@ func TestConfig_WithEnvPrefix(t *testing.T) {
|
||||||
assert.Equal(t, "secret", setting)
|
assert.Equal(t, "secret", setting)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConfig_WithEnvPrefix_TrailingUnderscore_Good(t *testing.T) {
|
|
||||||
t.Setenv("MYAPP_SETTING", "secret")
|
|
||||||
|
|
||||||
m := coreio.NewMockMedium()
|
|
||||||
cfg, err := New(WithMedium(m), WithEnvPrefix("MYAPP_"))
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
var setting string
|
|
||||||
err = cfg.Get("setting", &setting)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, "secret", setting)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestService_OnStartup_WithEnvPrefix_Good(t *testing.T) {
|
|
||||||
t.Setenv("MYAPP_SETTING", "secret")
|
|
||||||
|
|
||||||
m := coreio.NewMockMedium()
|
|
||||||
svc := &Service{
|
|
||||||
ServiceRuntime: core.NewServiceRuntime(nil, ServiceOptions{
|
|
||||||
EnvPrefix: "MYAPP",
|
|
||||||
Medium: m,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
|
|
||||||
err := svc.OnStartup(context.Background())
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
var setting string
|
|
||||||
err = svc.Get("setting", &setting)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, "secret", setting)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConfig_Get_EmptyKey(t *testing.T) {
|
func TestConfig_Get_EmptyKey(t *testing.T) {
|
||||||
m := coreio.NewMockMedium()
|
m := coreio.NewMockMedium()
|
||||||
m.Files["/config.yaml"] = "app:\n name: test\nversion: 1"
|
m.Files["/config.yaml"] = "app:\n name: test\nversion: 1"
|
||||||
|
|
@ -451,56 +279,3 @@ func TestConfig_Get_EmptyKey(t *testing.T) {
|
||||||
assert.Equal(t, "test", full.App.Name)
|
assert.Equal(t, "test", full.App.Name)
|
||||||
assert.Equal(t, 1, full.Version)
|
assert.Equal(t, 1, full.Version)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ExampleConfig_Get() {
|
|
||||||
m := coreio.NewMockMedium()
|
|
||||||
|
|
||||||
cfg, _ := New(WithMedium(m), WithPath("/tmp/example/config.yaml"))
|
|
||||||
_ = cfg.Set("dev.editor", "vim")
|
|
||||||
|
|
||||||
var editor string
|
|
||||||
_ = cfg.Get("dev.editor", &editor)
|
|
||||||
|
|
||||||
fmt.Println(editor)
|
|
||||||
// Output: vim
|
|
||||||
}
|
|
||||||
|
|
||||||
func ExampleConfig_Commit() {
|
|
||||||
m := coreio.NewMockMedium()
|
|
||||||
|
|
||||||
cfg, _ := New(WithMedium(m), WithPath("/tmp/example/config.yaml"))
|
|
||||||
_ = cfg.Set("app.name", "core")
|
|
||||||
_ = cfg.Commit()
|
|
||||||
|
|
||||||
content, _ := m.Read("/tmp/example/config.yaml")
|
|
||||||
fmt.Print(content)
|
|
||||||
// Output:
|
|
||||||
// app:
|
|
||||||
// name: core
|
|
||||||
}
|
|
||||||
|
|
||||||
func ExampleEnv() {
|
|
||||||
t := "EXAMPLE_FOO_BAR"
|
|
||||||
_ = os.Setenv(t, "baz")
|
|
||||||
defer os.Unsetenv(t)
|
|
||||||
|
|
||||||
for key, value := range Env("EXAMPLE_") {
|
|
||||||
fmt.Printf("%s=%s\n", key, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Output: foo.bar=baz
|
|
||||||
}
|
|
||||||
|
|
||||||
func ExampleConfig_LoadFile() {
|
|
||||||
m := coreio.NewMockMedium()
|
|
||||||
m.Files["/.env"] = "FOO=bar\n"
|
|
||||||
|
|
||||||
cfg, _ := New(WithMedium(m), WithPath("/config.yaml"))
|
|
||||||
_ = cfg.LoadFile(m, "/.env")
|
|
||||||
|
|
||||||
var foo string
|
|
||||||
_ = cfg.Get("foo", &foo)
|
|
||||||
|
|
||||||
fmt.Println(foo)
|
|
||||||
// Output: bar
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,8 @@ description: Internal design of config -- dual-viper layering, the Medium abstra
|
||||||
```go
|
```go
|
||||||
type Config struct {
|
type Config struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
full *viper.Viper // full configuration (file + env + defaults)
|
v *viper.Viper // full configuration (file + env + defaults)
|
||||||
file *viper.Viper // file-backed configuration only (for persistence)
|
f *viper.Viper // file-backed configuration only (for persistence)
|
||||||
medium coreio.Medium
|
medium coreio.Medium
|
||||||
path string
|
path string
|
||||||
}
|
}
|
||||||
|
|
@ -28,10 +28,10 @@ type Config struct {
|
||||||
|
|
||||||
`Config` is the central type. It holds **two** Viper instances:
|
`Config` is the central type. It holds **two** Viper instances:
|
||||||
|
|
||||||
- **`full`** -- contains everything: file values, environment bindings, and explicit `Set()` calls. All reads go through `full`.
|
- **`v`** (full) -- contains everything: file values, environment bindings, and explicit `Set()` calls. All reads go through `v`.
|
||||||
- **`file`** -- contains only values that originated from the config file or were explicitly set via `Set()`. All writes (`Commit()`) go through `file`.
|
- **`f`** (file) -- contains only values that originated from the config file or were explicitly set via `Set()`. All writes (`Commit()`) go through `f`.
|
||||||
|
|
||||||
This dual-instance design is the key architectural decision. It solves the environment leakage problem: Viper merges environment variables into its settings map, which means a naive `SaveConfig()` would serialise env vars into the YAML file. By maintaining `file` as a clean copy, `Commit()` only persists what should be persisted.
|
This dual-instance design is the key architectural decision. It solves the environment leakage problem: Viper merges environment variables into its settings map, which means a naive `SaveConfig()` would serialise env vars into the YAML file. By maintaining `f` as a clean copy, `Commit()` only persists what should be persisted.
|
||||||
|
|
||||||
### Option
|
### Option
|
||||||
|
|
||||||
|
|
@ -91,19 +91,19 @@ func LoadEnv(prefix string) map[string]any // deprecated
|
||||||
```
|
```
|
||||||
New(opts...)
|
New(opts...)
|
||||||
|
|
|
|
||||||
+-- create two viper instances (full, file)
|
+-- create two viper instances (v, f)
|
||||||
+-- set env prefix on full ("CORE_CONFIG_" default)
|
+-- set env prefix on v ("CORE_CONFIG_" default)
|
||||||
+-- set env key replacer ("." <-> "_")
|
+-- set env key replacer ("." <-> "_")
|
||||||
+-- apply functional options
|
+-- apply functional options
|
||||||
+-- default medium to io.Local if nil
|
+-- default medium to io.Local if nil
|
||||||
+-- default path to ~/.core/config.yaml if empty
|
+-- default path to ~/.core/config.yaml if empty
|
||||||
+-- enable full.AutomaticEnv()
|
+-- enable v.AutomaticEnv()
|
||||||
+-- if config file exists:
|
+-- if config file exists:
|
||||||
LoadFile(medium, path)
|
LoadFile(medium, path)
|
||||||
+-- medium.Read(path)
|
+-- medium.Read(path)
|
||||||
+-- detect config type from extension
|
+-- detect config type from extension
|
||||||
+-- file.MergeConfig(content)
|
+-- f.MergeConfig(content)
|
||||||
+-- full.MergeConfig(content)
|
+-- v.MergeConfig(content)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Read (`Get`)
|
### Read (`Get`)
|
||||||
|
|
@ -113,15 +113,15 @@ Get(key, &out)
|
||||||
|
|
|
|
||||||
+-- RLock
|
+-- RLock
|
||||||
+-- if key == "":
|
+-- if key == "":
|
||||||
| full.Unmarshal(out) // full config into a struct
|
| v.Unmarshal(out) // full config into a struct
|
||||||
+-- else:
|
+-- else:
|
||||||
| full.IsSet(key)?
|
| v.IsSet(key)?
|
||||||
| yes -> full.UnmarshalKey(key, out)
|
| yes -> v.UnmarshalKey(key, out)
|
||||||
| no -> error "key not found"
|
| no -> error "key not found"
|
||||||
+-- RUnlock
|
+-- RUnlock
|
||||||
```
|
```
|
||||||
|
|
||||||
Because `full` has `AutomaticEnv()` enabled, `full.IsSet(key)` returns true if the key exists in the file **or** as a `CORE_CONFIG_*` environment variable.
|
Because `v` has `AutomaticEnv()` enabled, `v.IsSet(key)` returns true if the key exists in the file **or** as a `CORE_CONFIG_*` environment variable.
|
||||||
|
|
||||||
### Write (`Set` + `Commit`)
|
### Write (`Set` + `Commit`)
|
||||||
|
|
||||||
|
|
@ -129,32 +129,32 @@ Because `full` has `AutomaticEnv()` enabled, `full.IsSet(key)` returns true if t
|
||||||
Set(key, value)
|
Set(key, value)
|
||||||
|
|
|
|
||||||
+-- Lock
|
+-- Lock
|
||||||
+-- file.Set(key, value) // track for persistence
|
+-- f.Set(key, value) // track for persistence
|
||||||
+-- full.Set(key, value) // make visible to Get()
|
+-- v.Set(key, value) // make visible to Get()
|
||||||
+-- Unlock
|
+-- Unlock
|
||||||
|
|
||||||
Commit()
|
Commit()
|
||||||
|
|
|
|
||||||
+-- Lock
|
+-- Lock
|
||||||
+-- Save(medium, path, file.AllSettings())
|
+-- Save(medium, path, f.AllSettings())
|
||||||
| +-- yaml.Marshal(data)
|
| +-- yaml.Marshal(data)
|
||||||
| +-- medium.EnsureDir(dir)
|
| +-- medium.EnsureDir(dir)
|
||||||
| +-- medium.Write(path, content)
|
| +-- medium.Write(path, content)
|
||||||
+-- Unlock
|
+-- Unlock
|
||||||
```
|
```
|
||||||
|
|
||||||
Note that `Commit()` serialises `file.AllSettings()`, not `full.AllSettings()`. This is intentional -- it prevents environment variable values from being written to the config file.
|
Note that `Commit()` serialises `f.AllSettings()`, not `v.AllSettings()`. This is intentional -- it prevents environment variable values from being written to the config file.
|
||||||
|
|
||||||
### File Loading (`LoadFile`)
|
### File Loading (`LoadFile`)
|
||||||
|
|
||||||
`LoadFile` supports YAML, JSON, TOML, and `.env` files. The config type is inferred from the file extension:
|
`LoadFile` supports both YAML and `.env` files. The config type is inferred from the file extension:
|
||||||
|
|
||||||
- `.yaml`, `.yml` -- YAML
|
- `.yaml`, `.yml` -- YAML
|
||||||
- `.json` -- JSON
|
- `.json` -- JSON
|
||||||
- `.toml` -- TOML
|
- `.toml` -- TOML
|
||||||
- `.env` (no extension, basename `.env`) -- dotenv format
|
- `.env` (no extension, basename `.env`) -- dotenv format
|
||||||
|
|
||||||
Content is merged (not replaced) into both `full` and `file` via `MergeConfig`, so multiple files can be layered.
|
Content is merged (not replaced) into both `v` and `f` via `MergeConfig`, so multiple files can be layered.
|
||||||
|
|
||||||
## Concurrency
|
## Concurrency
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ description: Layered configuration management for the Core framework with file,
|
||||||
|
|
||||||
# config
|
# config
|
||||||
|
|
||||||
`forge.lthn.ai/core/config` provides layered configuration management for applications built on the Core framework. It resolves values through a priority chain -- defaults, file, environment variables, and explicit `Set()` calls -- so that the same codebase works identically across local development, CI, and production without code changes.
|
`forge.lthn.ai/core/config` provides layered configuration management for applications built on the Core framework. It resolves values through a priority chain -- defaults, file, environment variables, flags -- so that the same codebase works identically across local development, CI, and production without code changes.
|
||||||
|
|
||||||
## Module Path
|
## Module Path
|
||||||
|
|
||||||
|
|
|
||||||
43
env.go
43
env.go
|
|
@ -3,42 +3,24 @@ package config
|
||||||
import (
|
import (
|
||||||
"iter"
|
"iter"
|
||||||
"os"
|
"os"
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func normaliseEnvPrefix(prefix string) string {
|
core "dappco.re/go/core"
|
||||||
if prefix == "" || strings.HasSuffix(prefix, "_") {
|
)
|
||||||
return prefix
|
|
||||||
}
|
|
||||||
return prefix + "_"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Env returns an iterator over environment variables with the given prefix,
|
// Env returns an iterator over environment variables with the given prefix,
|
||||||
// providing them as dot-notation keys and values.
|
// providing them as dot-notation keys and values.
|
||||||
//
|
//
|
||||||
// The prefix may be supplied with or without a trailing underscore.
|
|
||||||
//
|
|
||||||
// For example, with prefix "CORE_CONFIG_":
|
// For example, with prefix "CORE_CONFIG_":
|
||||||
//
|
//
|
||||||
// CORE_CONFIG_FOO_BAR=baz -> yields ("foo.bar", "baz")
|
// CORE_CONFIG_FOO_BAR=baz -> yields ("foo.bar", "baz")
|
||||||
func Env(prefix string) iter.Seq2[string, any] {
|
func Env(prefix string) iter.Seq2[string, any] {
|
||||||
return func(yield func(string, any) bool) {
|
return func(yield func(string, any) bool) {
|
||||||
prefix = normaliseEnvPrefix(prefix)
|
|
||||||
|
|
||||||
type entry struct {
|
|
||||||
key string
|
|
||||||
value any
|
|
||||||
}
|
|
||||||
|
|
||||||
var entries []entry
|
|
||||||
|
|
||||||
for _, env := range os.Environ() {
|
for _, env := range os.Environ() {
|
||||||
if !strings.HasPrefix(env, prefix) {
|
if !core.HasPrefix(env, prefix) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
parts := strings.SplitN(env, "=", 2)
|
parts := core.SplitN(env, "=", 2)
|
||||||
if len(parts) != 2 {
|
if len(parts) != 2 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -46,19 +28,12 @@ func Env(prefix string) iter.Seq2[string, any] {
|
||||||
name := parts[0]
|
name := parts[0]
|
||||||
value := parts[1]
|
value := parts[1]
|
||||||
|
|
||||||
key := strings.TrimPrefix(name, prefix)
|
// Strip prefix and convert to dot notation
|
||||||
key = strings.ToLower(key)
|
key := core.TrimPrefix(name, prefix)
|
||||||
key = strings.ReplaceAll(key, "_", ".")
|
key = core.Lower(key)
|
||||||
|
key = core.Replace(key, "_", ".")
|
||||||
|
|
||||||
entries = append(entries, entry{key: key, value: value})
|
if !yield(key, value) {
|
||||||
}
|
|
||||||
|
|
||||||
sort.Slice(entries, func(i, j int) bool {
|
|
||||||
return entries[i].key < entries[j].key
|
|
||||||
})
|
|
||||||
|
|
||||||
for _, entry := range entries {
|
|
||||||
if !yield(entry.key, entry.value) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
8
go.mod
8
go.mod
|
|
@ -1,17 +1,17 @@
|
||||||
module dappco.re/go/core/config
|
module forge.lthn.ai/core/config
|
||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
dappco.re/go/core v0.3.3
|
dappco.re/go/core v0.8.0-alpha.1
|
||||||
dappco.re/go/core/io v0.1.7
|
dappco.re/go/core/io v0.2.0
|
||||||
dappco.re/go/core/log v0.0.4
|
|
||||||
github.com/spf13/viper v1.21.0
|
github.com/spf13/viper v1.21.0
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
forge.lthn.ai/core/go-log v0.0.4 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||||
|
|
|
||||||
8
go.sum
8
go.sum
|
|
@ -1,7 +1,7 @@
|
||||||
forge.lthn.ai/core/go v0.3.3 h1:kYYZ2nRYy0/Be3cyuLJspRjLqTMxpckVyhb/7Sw2gd0=
|
dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk=
|
||||||
forge.lthn.ai/core/go v0.3.3/go.mod h1:Cp4ac25pghvO2iqOu59t1GyngTKVOzKB5/VPdhRi9CQ=
|
dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
|
||||||
forge.lthn.ai/core/go-io v0.1.7 h1:Tdb6sqh+zz1lsGJaNX9RFWM6MJ/RhSAyxfulLXrJsbk=
|
dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4=
|
||||||
forge.lthn.ai/core/go-io v0.1.7/go.mod h1:8lRLFk4Dnp5cR/Cyzh9WclD5566TbpdRgwcH7UZLWn4=
|
dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E=
|
||||||
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
|
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
|
||||||
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
|
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
|
|
|
||||||
37
service.go
37
service.go
|
|
@ -3,9 +3,8 @@ package config
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
coreio "forge.lthn.ai/core/go-io"
|
core "dappco.re/go/core"
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
coreio "dappco.re/go/core/io"
|
||||||
core "forge.lthn.ai/core/go/pkg/core"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Service wraps Config as a framework service with lifecycle support.
|
// Service wraps Config as a framework service with lifecycle support.
|
||||||
|
|
@ -18,49 +17,44 @@ type Service struct {
|
||||||
type ServiceOptions struct {
|
type ServiceOptions struct {
|
||||||
// Path overrides the default config file path.
|
// Path overrides the default config file path.
|
||||||
Path string
|
Path string
|
||||||
// EnvPrefix overrides the default environment variable prefix.
|
|
||||||
EnvPrefix string
|
|
||||||
// Medium overrides the default storage medium.
|
// Medium overrides the default storage medium.
|
||||||
Medium coreio.Medium
|
Medium coreio.Medium
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewConfigService creates a new config service factory for the Core framework.
|
// NewConfigService creates a new config service factory for the Core framework.
|
||||||
// Register it with core.WithService(config.NewConfigService).
|
// Register it with core.WithService(config.NewConfigService).
|
||||||
func NewConfigService(c *core.Core) (any, error) {
|
func NewConfigService(c *core.Core) core.Result {
|
||||||
svc := &Service{
|
svc := &Service{
|
||||||
ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}),
|
ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}),
|
||||||
}
|
}
|
||||||
return svc, nil
|
return core.Result{Value: svc, OK: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnStartup loads the configuration file during application startup.
|
// OnStartup loads the configuration file during application startup.
|
||||||
func (s *Service) OnStartup(_ context.Context) error {
|
func (s *Service) OnStartup(_ context.Context) core.Result {
|
||||||
opts := s.Opts()
|
opts := s.Options()
|
||||||
|
|
||||||
var configOpts []Option
|
var configOpts []Option
|
||||||
if opts.Path != "" {
|
if opts.Path != "" {
|
||||||
configOpts = append(configOpts, WithPath(opts.Path))
|
configOpts = append(configOpts, WithPath(opts.Path))
|
||||||
}
|
}
|
||||||
if opts.EnvPrefix != "" {
|
|
||||||
configOpts = append(configOpts, WithEnvPrefix(opts.EnvPrefix))
|
|
||||||
}
|
|
||||||
if opts.Medium != nil {
|
if opts.Medium != nil {
|
||||||
configOpts = append(configOpts, WithMedium(opts.Medium))
|
configOpts = append(configOpts, WithMedium(opts.Medium))
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := New(configOpts...)
|
cfg, err := New(configOpts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return coreerr.E("config.Service.OnStartup", "failed to create config", err)
|
return core.Result{Value: core.E("config.Service.OnStartup", "failed to create config", err)}
|
||||||
}
|
}
|
||||||
|
|
||||||
s.config = cfg
|
s.config = cfg
|
||||||
return nil
|
return core.Result{OK: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get retrieves a configuration value by key.
|
// Get retrieves a configuration value by key.
|
||||||
func (s *Service) Get(key string, out any) error {
|
func (s *Service) Get(key string, out any) error {
|
||||||
if s.config == nil {
|
if s.config == nil {
|
||||||
return coreerr.E("config.Service.Get", "config not loaded", nil)
|
return core.E("config.Service.Get", "config not loaded", nil)
|
||||||
}
|
}
|
||||||
return s.config.Get(key, out)
|
return s.config.Get(key, out)
|
||||||
}
|
}
|
||||||
|
|
@ -68,7 +62,7 @@ func (s *Service) Get(key string, out any) error {
|
||||||
// Set stores a configuration value by key.
|
// Set stores a configuration value by key.
|
||||||
func (s *Service) Set(key string, v any) error {
|
func (s *Service) Set(key string, v any) error {
|
||||||
if s.config == nil {
|
if s.config == nil {
|
||||||
return coreerr.E("config.Service.Set", "config not loaded", nil)
|
return core.E("config.Service.Set", "config not loaded", nil)
|
||||||
}
|
}
|
||||||
return s.config.Set(key, v)
|
return s.config.Set(key, v)
|
||||||
}
|
}
|
||||||
|
|
@ -76,7 +70,7 @@ func (s *Service) Set(key string, v any) error {
|
||||||
// Commit persists any configuration changes to disk.
|
// Commit persists any configuration changes to disk.
|
||||||
func (s *Service) Commit() error {
|
func (s *Service) Commit() error {
|
||||||
if s.config == nil {
|
if s.config == nil {
|
||||||
return coreerr.E("config.Service.Commit", "config not loaded", nil)
|
return core.E("config.Service.Commit", "config not loaded", nil)
|
||||||
}
|
}
|
||||||
return s.config.Commit()
|
return s.config.Commit()
|
||||||
}
|
}
|
||||||
|
|
@ -84,13 +78,10 @@ func (s *Service) Commit() error {
|
||||||
// LoadFile merges a configuration file into the central configuration.
|
// LoadFile merges a configuration file into the central configuration.
|
||||||
func (s *Service) LoadFile(m coreio.Medium, path string) error {
|
func (s *Service) LoadFile(m coreio.Medium, path string) error {
|
||||||
if s.config == nil {
|
if s.config == nil {
|
||||||
return coreerr.E("config.Service.LoadFile", "config not loaded", nil)
|
return core.E("config.Service.LoadFile", "config not loaded", nil)
|
||||||
}
|
}
|
||||||
return s.config.LoadFile(m, path)
|
return s.config.LoadFile(m, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure Service implements core.Config and Startable at compile time.
|
// Ensure Service implements core.Startable at compile time.
|
||||||
var (
|
var _ core.Startable = (*Service)(nil)
|
||||||
_ core.Config = (*Service)(nil)
|
|
||||||
_ core.Startable = (*Service)(nil)
|
|
||||||
)
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue