diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b51c70b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +cover.out diff --git a/README.md b/README.md index f4d2a24..112f1c3 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Originally built to support [grlx](http://github.com/gogrlx/grlx). go get github.com/taigrr/jety ``` -Requires Go 1.26.1 or later. +Requires Go 1.26.2 or later. ## Quick Start @@ -133,7 +133,6 @@ export MYAPP_SERVICES_CLOUD_VAR=override_value | ------------------------ | ------------------------ | | `Set(key, value)` | Set a value | | `SetDefault(key, value)` | Set a default value | -| `Delete(key)` | Remove a key | | `Sub(key)` | Get scoped sub-config | | `Get(key)` | Get raw value | | `GetString(key)` | Get as string | @@ -148,6 +147,8 @@ export MYAPP_SERVICES_CLOUD_VAR=override_value | `IsSet(key)` | Check if key has a value | | `AllKeys()` | List all known keys | | `AllSettings()` | Get all values as a map | +| `Unmarshal(target)` | Unmarshal config to struct | +| `UnmarshalKey(key, target)` | Unmarshal a key to struct | ### Environment @@ -156,6 +157,45 @@ export MYAPP_SERVICES_CLOUD_VAR=override_value | `WithEnvPrefix(prefix)` | Filter env vars by prefix (strips prefix from keys) | | `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 See [LICENSE](LICENSE) file. diff --git a/default.go b/default.go index a9589ed..cbefdd7 100644 --- a/default.go +++ b/default.go @@ -111,3 +111,11 @@ func AllKeys() []string { func AllSettings() map[string]any { return defaultConfigManager.AllSettings() } + +func Unmarshal(target any) error { + return defaultConfigManager.Unmarshal(target) +} + +func UnmarshalKey(key string, target any) error { + return defaultConfigManager.UnmarshalKey(key, target) +} diff --git a/go.mod b/go.mod index 5be6595..5e7046c 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/taigrr/jety -go 1.26.1 +go 1.26.2 require ( github.com/BurntSushi/toml v1.6.0 diff --git a/unmarshal.go b/unmarshal.go new file mode 100644 index 0000000..8c33f80 --- /dev/null +++ b/unmarshal.go @@ -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 +} diff --git a/unmarshal_test.go b/unmarshal_test.go new file mode 100644 index 0000000..ac0152b --- /dev/null +++ b/unmarshal_test.go @@ -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() +}