mirror of
https://github.com/taigrr/jety.git
synced 2026-04-15 01:40:47 -07:00
Compare commits
5 Commits
cd/ci-upda
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e77438e8c | |||
| 0c0fc0320c | |||
| 6ec3d79700 | |||
| a81a2027ae | |||
| ff8a3444f1 |
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
cover.out
|
||||||
44
README.md
44
README.md
@@ -15,7 +15,7 @@ Originally built to support [grlx](http://github.com/gogrlx/grlx).
|
|||||||
go get github.com/taigrr/jety
|
go get github.com/taigrr/jety
|
||||||
```
|
```
|
||||||
|
|
||||||
Requires Go 1.26.1 or later.
|
Requires Go 1.26.2 or later.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -133,7 +133,6 @@ export MYAPP_SERVICES_CLOUD_VAR=override_value
|
|||||||
| ------------------------ | ------------------------ |
|
| ------------------------ | ------------------------ |
|
||||||
| `Set(key, value)` | Set a value |
|
| `Set(key, value)` | Set a value |
|
||||||
| `SetDefault(key, value)` | Set a default value |
|
| `SetDefault(key, value)` | Set a default value |
|
||||||
| `Delete(key)` | Remove a key |
|
|
||||||
| `Sub(key)` | Get scoped sub-config |
|
| `Sub(key)` | Get scoped sub-config |
|
||||||
| `Get(key)` | Get raw value |
|
| `Get(key)` | Get raw value |
|
||||||
| `GetString(key)` | Get as string |
|
| `GetString(key)` | Get as string |
|
||||||
@@ -148,6 +147,8 @@ export MYAPP_SERVICES_CLOUD_VAR=override_value
|
|||||||
| `IsSet(key)` | Check if key has a value |
|
| `IsSet(key)` | Check if key has a value |
|
||||||
| `AllKeys()` | List all known keys |
|
| `AllKeys()` | List all known keys |
|
||||||
| `AllSettings()` | Get all values as a map |
|
| `AllSettings()` | Get all values as a map |
|
||||||
|
| `Unmarshal(target)` | Unmarshal config to struct |
|
||||||
|
| `UnmarshalKey(key, target)` | Unmarshal a key to struct |
|
||||||
|
|
||||||
### Environment
|
### Environment
|
||||||
|
|
||||||
@@ -156,6 +157,45 @@ export MYAPP_SERVICES_CLOUD_VAR=override_value
|
|||||||
| `WithEnvPrefix(prefix)` | Filter env vars by prefix (strips prefix from keys) |
|
| `WithEnvPrefix(prefix)` | Filter env vars by prefix (strips prefix from keys) |
|
||||||
| `SetEnvPrefix(prefix)` | Set prefix for env var lookups |
|
| `SetEnvPrefix(prefix)` | Set prefix for env var lookups |
|
||||||
|
|
||||||
|
## Struct Unmarshaling
|
||||||
|
|
||||||
|
Unmarshal your configuration directly into Go structs:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Config struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Debug bool `json:"debug"`
|
||||||
|
}
|
||||||
|
|
||||||
|
jety.SetConfigFile("config.json")
|
||||||
|
jety.SetConfigType("json")
|
||||||
|
jety.ReadInConfig()
|
||||||
|
|
||||||
|
var cfg Config
|
||||||
|
if err := jety.Unmarshal(&cfg); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For nested sections, use `UnmarshalKey`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type DatabaseConfig struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var dbCfg DatabaseConfig
|
||||||
|
if err := jety.UnmarshalKey("database", &dbCfg); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Struct tags use `json:"..."` since the unmarshaling uses JSON round-trip internally.
|
||||||
|
Dot notation works with `UnmarshalKey` for deeply nested values (e.g., `"services.api"`).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
See [LICENSE](LICENSE) file.
|
See [LICENSE](LICENSE) file.
|
||||||
|
|||||||
12
default.go
12
default.go
@@ -88,10 +88,6 @@ func SetString(key string, value string) {
|
|||||||
defaultConfigManager.SetString(key, value)
|
defaultConfigManager.SetString(key, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Delete(key string) {
|
|
||||||
defaultConfigManager.Delete(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
func Sub(key string) *ConfigManager {
|
func Sub(key string) *ConfigManager {
|
||||||
return defaultConfigManager.Sub(key)
|
return defaultConfigManager.Sub(key)
|
||||||
}
|
}
|
||||||
@@ -115,3 +111,11 @@ func AllKeys() []string {
|
|||||||
func AllSettings() map[string]any {
|
func AllSettings() map[string]any {
|
||||||
return defaultConfigManager.AllSettings()
|
return defaultConfigManager.AllSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Unmarshal(target any) error {
|
||||||
|
return defaultConfigManager.Unmarshal(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UnmarshalKey(key string, target any) error {
|
||||||
|
return defaultConfigManager.UnmarshalKey(key, target)
|
||||||
|
}
|
||||||
|
|||||||
54
getters.go
54
getters.go
@@ -8,17 +8,71 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// resolve looks up a key in combinedConfig, falling back to envConfig.
|
// resolve looks up a key in combinedConfig, falling back to envConfig.
|
||||||
|
// It supports dot notation (e.g., "services.mas.server") to traverse nested maps.
|
||||||
func (c *ConfigManager) resolve(key string) (ConfigMap, bool) {
|
func (c *ConfigManager) resolve(key string) (ConfigMap, bool) {
|
||||||
lower := strings.ToLower(key)
|
lower := strings.ToLower(key)
|
||||||
|
|
||||||
|
// First, try direct lookup (for top-level keys or keys without dots)
|
||||||
if v, ok := c.combinedConfig[lower]; ok {
|
if v, ok := c.combinedConfig[lower]; ok {
|
||||||
return v, true
|
return v, true
|
||||||
}
|
}
|
||||||
if v, ok := c.envConfig[lower]; ok {
|
if v, ok := c.envConfig[lower]; ok {
|
||||||
return v, true
|
return v, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If key contains dots, try traversing nested maps
|
||||||
|
if strings.Contains(lower, ".") {
|
||||||
|
if v, ok := c.resolveNested(lower, c.combinedConfig); ok {
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
if v, ok := c.resolveNested(lower, c.envConfig); ok {
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return ConfigMap{}, false
|
return ConfigMap{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveNested traverses nested maps using dot-separated key paths.
|
||||||
|
func (c *ConfigManager) resolveNested(key string, config map[string]ConfigMap) (ConfigMap, bool) {
|
||||||
|
parts := strings.Split(key, ".")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return ConfigMap{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the first part in the config
|
||||||
|
firstPart := parts[0]
|
||||||
|
entry, ok := config[firstPart]
|
||||||
|
if !ok {
|
||||||
|
return ConfigMap{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traverse the remaining parts through nested maps
|
||||||
|
current := entry.Value
|
||||||
|
for i := 1; i < len(parts); i++ {
|
||||||
|
m, ok := current.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return ConfigMap{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try case-insensitive lookup in the nested map
|
||||||
|
part := parts[i]
|
||||||
|
found := false
|
||||||
|
for k, v := range m {
|
||||||
|
if strings.EqualFold(k, part) {
|
||||||
|
current = v
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return ConfigMap{}, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ConfigMap{Key: key, Value: current}, true
|
||||||
|
}
|
||||||
|
|
||||||
func (c *ConfigManager) Get(key string) any {
|
func (c *ConfigManager) Get(key string) any {
|
||||||
c.mutex.RLock()
|
c.mutex.RLock()
|
||||||
defer c.mutex.RUnlock()
|
defer c.mutex.RUnlock()
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -1,6 +1,6 @@
|
|||||||
module github.com/taigrr/jety
|
module github.com/taigrr/jety
|
||||||
|
|
||||||
go 1.26.1
|
go 1.26.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/BurntSushi/toml v1.6.0
|
github.com/BurntSushi/toml v1.6.0
|
||||||
|
|||||||
225
jety_test.go
225
jety_test.go
@@ -1555,81 +1555,6 @@ func TestPackageLevelGetInt64(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDelete(t *testing.T) {
|
|
||||||
cm := NewConfigManager()
|
|
||||||
cm.SetDefault("keep", "yes")
|
|
||||||
cm.SetDefault("remove", "default")
|
|
||||||
cm.Set("remove", "override")
|
|
||||||
|
|
||||||
if !cm.IsSet("remove") {
|
|
||||||
t.Fatal("remove should be set before delete")
|
|
||||||
}
|
|
||||||
|
|
||||||
cm.Delete("remove")
|
|
||||||
|
|
||||||
if cm.IsSet("remove") {
|
|
||||||
t.Error("remove should not be set after delete")
|
|
||||||
}
|
|
||||||
if cm.Get("remove") != nil {
|
|
||||||
t.Error("Get(remove) should return nil after delete")
|
|
||||||
}
|
|
||||||
if cm.GetString("keep") != "yes" {
|
|
||||||
t.Error("keep should be unaffected by deleting remove")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeleteFromAllLayers(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
configFile := filepath.Join(dir, "config.toml")
|
|
||||||
if err := os.WriteFile(configFile, []byte(`filekey = "fromfile"`), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cm := NewConfigManager()
|
|
||||||
cm.SetConfigFile(configFile)
|
|
||||||
if err := cm.SetConfigType("toml"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
cm.SetDefault("filekey", "default")
|
|
||||||
if err := cm.ReadInConfig(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
cm.Set("filekey", "override")
|
|
||||||
|
|
||||||
if cm.GetString("filekey") != "override" {
|
|
||||||
t.Fatal("expected override value before delete")
|
|
||||||
}
|
|
||||||
|
|
||||||
cm.Delete("filekey")
|
|
||||||
|
|
||||||
if cm.IsSet("filekey") {
|
|
||||||
t.Error("filekey should not be set after delete from all layers")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeleteCaseInsensitive(t *testing.T) {
|
|
||||||
cm := NewConfigManager()
|
|
||||||
cm.Set("MyKey", "value")
|
|
||||||
|
|
||||||
cm.Delete("MYKEY")
|
|
||||||
|
|
||||||
if cm.IsSet("mykey") {
|
|
||||||
t.Error("delete should be case-insensitive")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPackageLevelDelete(t *testing.T) {
|
|
||||||
defaultConfigManager = NewConfigManager()
|
|
||||||
Set("temp", "value")
|
|
||||||
if !IsSet("temp") {
|
|
||||||
t.Fatal("temp should be set")
|
|
||||||
}
|
|
||||||
Delete("temp")
|
|
||||||
if IsSet("temp") {
|
|
||||||
t.Error("temp should not be set after package-level Delete")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSub(t *testing.T) {
|
func TestSub(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
configFile := filepath.Join(dir, "config.toml")
|
configFile := filepath.Join(dir, "config.toml")
|
||||||
@@ -1721,3 +1646,153 @@ func TestPackageLevelSub(t *testing.T) {
|
|||||||
t.Errorf("Sub(db).GetInt(port) = %d, want 5432", sub.GetInt("port"))
|
t.Errorf("Sub(db).GetInt(port) = %d, want 5432", sub.GetInt("port"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDotNotationAccess(t *testing.T) {
|
||||||
|
const tomlWithNested = `
|
||||||
|
port = 8080
|
||||||
|
|
||||||
|
[services.mas]
|
||||||
|
server = "192.168.1.15"
|
||||||
|
user = "central"
|
||||||
|
disabled = false
|
||||||
|
|
||||||
|
[services.salesforce.prod]
|
||||||
|
host = "sf.example.com"
|
||||||
|
port = 443
|
||||||
|
`
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
configFile := filepath.Join(dir, "nested.toml")
|
||||||
|
if err := os.WriteFile(configFile, []byte(tomlWithNested), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cm := NewConfigManager()
|
||||||
|
cm.SetConfigFile(configFile)
|
||||||
|
if err := cm.SetConfigType("toml"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := cm.ReadInConfig(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top-level key should still work
|
||||||
|
if got := cm.GetInt("port"); got != 8080 {
|
||||||
|
t.Errorf("GetInt(port) = %d, want 8080", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dot notation for nested string
|
||||||
|
if got := cm.GetString("services.mas.server"); got != "192.168.1.15" {
|
||||||
|
t.Errorf("GetString(services.mas.server) = %q, want %q", got, "192.168.1.15")
|
||||||
|
}
|
||||||
|
if got := cm.GetString("services.mas.user"); got != "central" {
|
||||||
|
t.Errorf("GetString(services.mas.user) = %q, want %q", got, "central")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dot notation for nested bool
|
||||||
|
if got := cm.GetBool("services.mas.disabled"); got != false {
|
||||||
|
t.Errorf("GetBool(services.mas.disabled) = %v, want false", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dot notation for nested map
|
||||||
|
masMap := cm.GetStringMap("services.mas")
|
||||||
|
if masMap == nil {
|
||||||
|
t.Fatal("GetStringMap(services.mas) = nil")
|
||||||
|
}
|
||||||
|
if masMap["server"] != "192.168.1.15" {
|
||||||
|
t.Errorf("services.mas map server = %v, want %q", masMap["server"], "192.168.1.15")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Three levels deep
|
||||||
|
if got := cm.GetString("services.salesforce.prod.host"); got != "sf.example.com" {
|
||||||
|
t.Errorf("GetString(services.salesforce.prod.host) = %q, want %q", got, "sf.example.com")
|
||||||
|
}
|
||||||
|
if got := cm.GetInt("services.salesforce.prod.port"); got != 443 {
|
||||||
|
t.Errorf("GetInt(services.salesforce.prod.port) = %d, want 443", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStringMap at intermediate level
|
||||||
|
sfProdMap := cm.GetStringMap("services.salesforce.prod")
|
||||||
|
if sfProdMap == nil {
|
||||||
|
t.Fatal("GetStringMap(services.salesforce.prod) = nil")
|
||||||
|
}
|
||||||
|
if sfProdMap["host"] != "sf.example.com" {
|
||||||
|
t.Errorf("salesforce.prod map host = %v, want %q", sfProdMap["host"], "sf.example.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nonexistent nested key should return zero value
|
||||||
|
if got := cm.GetString("services.mas.nonexistent"); got != "" {
|
||||||
|
t.Errorf("GetString(services.mas.nonexistent) = %q, want empty", got)
|
||||||
|
}
|
||||||
|
if got := cm.GetStringMap("services.nonexistent"); got != nil {
|
||||||
|
t.Errorf("GetStringMap(services.nonexistent) = %v, want nil", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDotNotationCaseInsensitive(t *testing.T) {
|
||||||
|
cm := NewConfigManager()
|
||||||
|
cm.Set("services", map[string]any{
|
||||||
|
"MAS": map[string]any{
|
||||||
|
"Server": "192.168.1.15",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Should work regardless of case
|
||||||
|
if got := cm.GetString("services.mas.server"); got != "192.168.1.15" {
|
||||||
|
t.Errorf("GetString(services.mas.server) = %q, want %q", got, "192.168.1.15")
|
||||||
|
}
|
||||||
|
if got := cm.GetString("SERVICES.MAS.SERVER"); got != "192.168.1.15" {
|
||||||
|
t.Errorf("GetString(SERVICES.MAS.SERVER) = %q, want %q", got, "192.168.1.15")
|
||||||
|
}
|
||||||
|
if got := cm.GetString("Services.Mas.Server"); got != "192.168.1.15" {
|
||||||
|
t.Errorf("GetString(Services.Mas.Server) = %q, want %q", got, "192.168.1.15")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDotNotationWithAllGetters(t *testing.T) {
|
||||||
|
cm := NewConfigManager()
|
||||||
|
cm.Set("config", map[string]any{
|
||||||
|
"nested": map[string]any{
|
||||||
|
"string": "hello",
|
||||||
|
"int": 42,
|
||||||
|
"int64": int64(9223372036854775807),
|
||||||
|
"float": 3.14,
|
||||||
|
"bool": true,
|
||||||
|
"dur": "30s",
|
||||||
|
"strings": []string{"a", "b", "c"},
|
||||||
|
"ints": []any{1, 2, 3},
|
||||||
|
"map": map[string]any{"key": "value"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if got := cm.Get("config.nested.string"); got != "hello" {
|
||||||
|
t.Errorf("Get(config.nested.string) = %v, want %q", got, "hello")
|
||||||
|
}
|
||||||
|
if got := cm.GetString("config.nested.string"); got != "hello" {
|
||||||
|
t.Errorf("GetString(config.nested.string) = %q, want %q", got, "hello")
|
||||||
|
}
|
||||||
|
if got := cm.GetInt("config.nested.int"); got != 42 {
|
||||||
|
t.Errorf("GetInt(config.nested.int) = %d, want 42", got)
|
||||||
|
}
|
||||||
|
if got := cm.GetInt64("config.nested.int64"); got != 9223372036854775807 {
|
||||||
|
t.Errorf("GetInt64(config.nested.int64) = %d, want 9223372036854775807", got)
|
||||||
|
}
|
||||||
|
if got := cm.GetFloat64("config.nested.float"); got != 3.14 {
|
||||||
|
t.Errorf("GetFloat64(config.nested.float) = %f, want 3.14", got)
|
||||||
|
}
|
||||||
|
if got := cm.GetBool("config.nested.bool"); got != true {
|
||||||
|
t.Errorf("GetBool(config.nested.bool) = %v, want true", got)
|
||||||
|
}
|
||||||
|
if got := cm.GetDuration("config.nested.dur"); got != 30*time.Second {
|
||||||
|
t.Errorf("GetDuration(config.nested.dur) = %v, want 30s", got)
|
||||||
|
}
|
||||||
|
if got := cm.GetStringSlice("config.nested.strings"); len(got) != 3 || got[0] != "a" {
|
||||||
|
t.Errorf("GetStringSlice(config.nested.strings) = %v, want [a b c]", got)
|
||||||
|
}
|
||||||
|
if got := cm.GetIntSlice("config.nested.ints"); len(got) != 3 || got[0] != 1 {
|
||||||
|
t.Errorf("GetIntSlice(config.nested.ints) = %v, want [1 2 3]", got)
|
||||||
|
}
|
||||||
|
if got := cm.GetStringMap("config.nested.map"); got == nil || got["key"] != "value" {
|
||||||
|
t.Errorf("GetStringMap(config.nested.map) = %v, want map[key:value]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
13
setters.go
13
setters.go
@@ -28,19 +28,6 @@ func (c *ConfigManager) Set(key string, value any) {
|
|||||||
c.combinedConfig[lower] = ConfigMap{Key: key, Value: value}
|
c.combinedConfig[lower] = ConfigMap{Key: key, Value: value}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete removes a key from all configuration layers (overrides, file,
|
|
||||||
// defaults) and rebuilds the combined configuration. Environment variables
|
|
||||||
// are not affected since they are loaded from the process environment.
|
|
||||||
func (c *ConfigManager) Delete(key string) {
|
|
||||||
c.mutex.Lock()
|
|
||||||
lower := strings.ToLower(key)
|
|
||||||
delete(c.overrideConfig, lower)
|
|
||||||
delete(c.fileConfig, lower)
|
|
||||||
delete(c.defaultConfig, lower)
|
|
||||||
delete(c.combinedConfig, lower)
|
|
||||||
c.mutex.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *ConfigManager) SetDefault(key string, value any) {
|
func (c *ConfigManager) SetDefault(key string, value any) {
|
||||||
c.mutex.Lock()
|
c.mutex.Lock()
|
||||||
defer c.mutex.Unlock()
|
defer c.mutex.Unlock()
|
||||||
|
|||||||
49
unmarshal.go
Normal file
49
unmarshal.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package jety
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Unmarshal unmarshals the full combined configuration into the provided
|
||||||
|
// struct pointer. It works by marshaling the settings map to JSON and then
|
||||||
|
// unmarshaling into the target, so struct field tags should use `json:"key"`.
|
||||||
|
// The target must be a non-nil pointer to a struct.
|
||||||
|
func (c *ConfigManager) Unmarshal(target any) error {
|
||||||
|
settings := c.AllSettings()
|
||||||
|
return mapToStruct(settings, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalKey unmarshals a specific key's value into the provided struct
|
||||||
|
// pointer. The key must refer to a map value (e.g., a nested TOML/YAML/JSON
|
||||||
|
// section). Supports dot notation for nested keys.
|
||||||
|
func (c *ConfigManager) UnmarshalKey(key string, target any) error {
|
||||||
|
c.mutex.RLock()
|
||||||
|
v, ok := c.resolve(key)
|
||||||
|
c.mutex.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("key %q not found", key)
|
||||||
|
}
|
||||||
|
m, ok := v.Value.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
// If the value is not a map, try direct JSON round-trip
|
||||||
|
data, err := json.Marshal(v.Value)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot marshal value for key %q: %w", key, err)
|
||||||
|
}
|
||||||
|
return json.Unmarshal(data, target)
|
||||||
|
}
|
||||||
|
return mapToStruct(m, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mapToStruct converts a map[string]any to a struct via JSON round-trip.
|
||||||
|
func mapToStruct(m map[string]any, target any) error {
|
||||||
|
data, err := json.Marshal(m)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot marshal config: %w", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, target); err != nil {
|
||||||
|
return fmt.Errorf("cannot unmarshal config: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
333
unmarshal_test.go
Normal file
333
unmarshal_test.go
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
package jety
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUnmarshal(t *testing.T) {
|
||||||
|
type ServerConfig struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
}
|
||||||
|
type AppConfig struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Debug bool `json:"debug"`
|
||||||
|
Server ServerConfig `json:"server"`
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
configFile := filepath.Join(dir, "config.json")
|
||||||
|
err := os.WriteFile(configFile, []byte(`{
|
||||||
|
"name": "myapp",
|
||||||
|
"debug": true,
|
||||||
|
"server": {
|
||||||
|
"host": "localhost",
|
||||||
|
"port": 8080
|
||||||
|
}
|
||||||
|
}`), 0644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cm := NewConfigManager()
|
||||||
|
cm.SetConfigFile(configFile)
|
||||||
|
if err := cm.SetConfigType("json"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := cm.ReadInConfig(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg AppConfig
|
||||||
|
if err := cm.Unmarshal(&cfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Name != "myapp" {
|
||||||
|
t.Errorf("expected name 'myapp', got %q", cfg.Name)
|
||||||
|
}
|
||||||
|
if !cfg.Debug {
|
||||||
|
t.Error("expected debug to be true")
|
||||||
|
}
|
||||||
|
if cfg.Server.Host != "localhost" {
|
||||||
|
t.Errorf("expected host 'localhost', got %q", cfg.Server.Host)
|
||||||
|
}
|
||||||
|
if cfg.Server.Port != 8080 {
|
||||||
|
t.Errorf("expected port 8080, got %d", cfg.Server.Port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmarshalWithDefaults(t *testing.T) {
|
||||||
|
type Config struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Timeout int `json:"timeout"`
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
configFile := filepath.Join(dir, "config.json")
|
||||||
|
err := os.WriteFile(configFile, []byte(`{
|
||||||
|
"host": "example.com"
|
||||||
|
}`), 0644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cm := NewConfigManager()
|
||||||
|
cm.SetDefault("port", 3000)
|
||||||
|
cm.SetDefault("timeout", 30)
|
||||||
|
cm.SetConfigFile(configFile)
|
||||||
|
if err := cm.SetConfigType("json"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := cm.ReadInConfig(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg Config
|
||||||
|
if err := cm.Unmarshal(&cfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Host != "example.com" {
|
||||||
|
t.Errorf("expected host 'example.com', got %q", cfg.Host)
|
||||||
|
}
|
||||||
|
if cfg.Port != 3000 {
|
||||||
|
t.Errorf("expected port 3000, got %d", cfg.Port)
|
||||||
|
}
|
||||||
|
if cfg.Timeout != 30 {
|
||||||
|
t.Errorf("expected timeout 30, got %d", cfg.Timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmarshalKey(t *testing.T) {
|
||||||
|
type DatabaseConfig struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
configFile := filepath.Join(dir, "config.json")
|
||||||
|
err := os.WriteFile(configFile, []byte(`{
|
||||||
|
"database": {
|
||||||
|
"host": "db.example.com",
|
||||||
|
"port": 5432,
|
||||||
|
"name": "mydb"
|
||||||
|
},
|
||||||
|
"cache": {
|
||||||
|
"host": "cache.example.com",
|
||||||
|
"port": 6379
|
||||||
|
}
|
||||||
|
}`), 0644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cm := NewConfigManager()
|
||||||
|
cm.SetConfigFile(configFile)
|
||||||
|
if err := cm.SetConfigType("json"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := cm.ReadInConfig(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var dbCfg DatabaseConfig
|
||||||
|
if err := cm.UnmarshalKey("database", &dbCfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if dbCfg.Host != "db.example.com" {
|
||||||
|
t.Errorf("expected host 'db.example.com', got %q", dbCfg.Host)
|
||||||
|
}
|
||||||
|
if dbCfg.Port != 5432 {
|
||||||
|
t.Errorf("expected port 5432, got %d", dbCfg.Port)
|
||||||
|
}
|
||||||
|
if dbCfg.Name != "mydb" {
|
||||||
|
t.Errorf("expected name 'mydb', got %q", dbCfg.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmarshalKeyNotFound(t *testing.T) {
|
||||||
|
cm := NewConfigManager()
|
||||||
|
var target struct{}
|
||||||
|
err := cm.UnmarshalKey("nonexistent", &target)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for missing key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmarshalKeyScalarValue(t *testing.T) {
|
||||||
|
cm := NewConfigManager()
|
||||||
|
cm.Set("count", 42)
|
||||||
|
|
||||||
|
var count int
|
||||||
|
if err := cm.UnmarshalKey("count", &count); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if count != 42 {
|
||||||
|
t.Errorf("expected 42, got %d", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmarshalKeyNestedDotNotation(t *testing.T) {
|
||||||
|
type Inner struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
configFile := filepath.Join(dir, "config.json")
|
||||||
|
err := os.WriteFile(configFile, []byte(`{
|
||||||
|
"services": {
|
||||||
|
"api": {
|
||||||
|
"value": "hello"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`), 0644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cm := NewConfigManager()
|
||||||
|
cm.SetConfigFile(configFile)
|
||||||
|
if err := cm.SetConfigType("json"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := cm.ReadInConfig(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var inner Inner
|
||||||
|
if err := cm.UnmarshalKey("services.api", &inner); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if inner.Value != "hello" {
|
||||||
|
t.Errorf("expected 'hello', got %q", inner.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmarshalTOML(t *testing.T) {
|
||||||
|
type Config struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
configFile := filepath.Join(dir, "config.toml")
|
||||||
|
err := os.WriteFile(configFile, []byte(`
|
||||||
|
title = "My App"
|
||||||
|
port = 9090
|
||||||
|
`), 0644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cm := NewConfigManager()
|
||||||
|
cm.SetConfigFile(configFile)
|
||||||
|
if err := cm.SetConfigType("toml"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := cm.ReadInConfig(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg Config
|
||||||
|
if err := cm.Unmarshal(&cfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Title != "My App" {
|
||||||
|
t.Errorf("expected title 'My App', got %q", cfg.Title)
|
||||||
|
}
|
||||||
|
if cfg.Port != 9090 {
|
||||||
|
t.Errorf("expected port 9090, got %d", cfg.Port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmarshalYAML(t *testing.T) {
|
||||||
|
type Config struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
configFile := filepath.Join(dir, "config.yaml")
|
||||||
|
err := os.WriteFile(configFile, []byte(`
|
||||||
|
name: testapp
|
||||||
|
tags:
|
||||||
|
- web
|
||||||
|
- api
|
||||||
|
enabled: true
|
||||||
|
`), 0644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cm := NewConfigManager()
|
||||||
|
cm.SetConfigFile(configFile)
|
||||||
|
if err := cm.SetConfigType("yaml"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := cm.ReadInConfig(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg Config
|
||||||
|
if err := cm.Unmarshal(&cfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Name != "testapp" {
|
||||||
|
t.Errorf("expected name 'testapp', got %q", cfg.Name)
|
||||||
|
}
|
||||||
|
if len(cfg.Tags) != 2 || cfg.Tags[0] != "web" || cfg.Tags[1] != "api" {
|
||||||
|
t.Errorf("expected tags [web api], got %v", cfg.Tags)
|
||||||
|
}
|
||||||
|
if !cfg.Enabled {
|
||||||
|
t.Error("expected enabled to be true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultUnmarshal(t *testing.T) {
|
||||||
|
// Test package-level Unmarshal function
|
||||||
|
type Config struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset default manager
|
||||||
|
defaultConfigManager = NewConfigManager()
|
||||||
|
Set("key", "value")
|
||||||
|
|
||||||
|
var cfg Config
|
||||||
|
if err := Unmarshal(&cfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg.Key != "value" {
|
||||||
|
t.Errorf("expected 'value', got %q", cfg.Key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore
|
||||||
|
defaultConfigManager = NewConfigManager()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultUnmarshalKey(t *testing.T) {
|
||||||
|
defaultConfigManager = NewConfigManager()
|
||||||
|
Set("section", map[string]any{"field": "data"})
|
||||||
|
|
||||||
|
type Section struct {
|
||||||
|
Field string `json:"field"`
|
||||||
|
}
|
||||||
|
var sec Section
|
||||||
|
if err := UnmarshalKey("section", &sec); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if sec.Field != "data" {
|
||||||
|
t.Errorf("expected 'data', got %q", sec.Field)
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultConfigManager = NewConfigManager()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user