13 Commits

Author SHA1 Message Date
9ff1fdc5ee fix!: SetEnvPrefix now re-reads env vars with prefix stripping
SetEnvPrefix was broken: it set the prefix string but never re-read
environment variables. This meant collapse() and SetDefault() couldn't
match prefixed env vars to their unprefixed config keys, so defaults
always won over env vars.

The fix makes SetEnvPrefix behave identically to WithEnvPrefix: it
re-reads os.Environ(), strips the prefix from matching keys, and
stores the stripped keys in envConfig. The envPrefix field is removed
entirely since keys are always pre-stripped.

BREAKING CHANGE: SetEnvPrefix now filters env vars to only those
matching the prefix (previously all env vars were accessible).
This matches the documented and expected behavior.
2026-03-01 20:42:30 +00:00
94b97a5825 chore(deps): update Go to 1.26.0, toml to v1.6.0, add tests for 97% coverage 2026-02-24 07:02:22 +00:00
21ea264b79 add nested env var doc 2026-01-24 20:58:47 -05:00
c4c05732f5 update to fixup some race conditions 2026-01-24 20:29:43 -05:00
59b8a9078f fix config => jety 2023-11-03 17:22:02 -07:00
550537be3b setting using default vkey 2023-11-03 16:39:30 -07:00
95bdc4d109 upadte switch for any 2023-11-03 12:27:04 -07:00
64d37d936f add check for empty config file 2023-11-03 04:40:53 -07:00
62dd32f61b fix slice returners 2023-11-03 04:16:29 -07:00
9c5923bd4e fix mutex 2023-11-03 03:42:21 -07:00
5643d4d262 send back custom error 2023-11-03 03:18:37 -07:00
ee74c94359 add config collapse after reading in 2023-11-03 03:01:51 -07:00
e84645ccfa fix readme order 2023-11-03 02:25:19 -07:00
9 changed files with 1691 additions and 66 deletions

171
README.md
View File

@@ -1,9 +1,172 @@
# JETY
JSON, ENV, YAML, TOML
JSON, ENV, TOML, YAML
This is a package for collapsing multiple configuration stores (env+json, env+yaml, env+toml) and writing them back to a centralized config.
A lightweight Go configuration management library supporting JSON, ENV, TOML, and YAML formats.
It provides viper-like `AutomaticEnv` functionality with fewer dependencies.
Originally built to support [grlx](http://github.com/gogrlx/grlx).
It should behave similarly to the AutomaticEnv functionality of viper, but without some of the extra heft of the depedendencies it carries.
## Installation
The inital purpose of this repo is to support the configuration requirements of [grlx](http://github.com/gogrlx/grlx), but development may continue to expand until more viper use cases and functionality are covered.
```bash
go get github.com/taigrr/jety
```
Requires Go 1.25.5 or later.
## Quick Start
```go
package main
import "github.com/taigrr/jety"
func main() {
// Set defaults
jety.SetDefault("port", 8080)
jety.SetDefault("host", "localhost")
// Environment variables are loaded automatically
// e.g., PORT=9000 overrides the default
// Read from config file
jety.SetConfigFile("config.toml")
jety.SetConfigType("toml")
if err := jety.ReadInConfig(); err != nil {
// handle error
}
// Get values (config file > env > default)
port := jety.GetInt("port")
host := jety.GetString("host")
}
```
## Features
- **Multiple formats**: JSON, TOML, YAML
- **Automatic env loading**: Environment variables loaded on init
- **Prefix filtering**: Filter env vars by prefix (e.g., `MYAPP_`)
- **Case-insensitive keys**: Keys normalized to lowercase
- **Type coercion**: Getters handle type conversion gracefully
- **Thread-safe**: Safe for concurrent access
- **Config precedence**: config file > environment > defaults
## Nested Configuration
For nested config structures like:
```toml
[services.cloud]
var = "xyz"
timeout = "30s"
[services.cloud.auth]
client_id = "abc123"
```
Access nested values using `GetStringMap` and type assertions:
```go
services := jety.GetStringMap("services")
cloud := services["cloud"].(map[string]any)
varValue := cloud["var"].(string) // "xyz"
// For deeper nesting
auth := cloud["auth"].(map[string]any)
clientID := auth["client_id"].(string) // "abc123"
```
### Environment Variable Overrides
Environment variables use uppercase keys. For nested config, the env var name is the key in uppercase:
```bash
# Override top-level key
export PORT=9000
# For nested keys, use the full key name in uppercase
export SERVICES_CLOUD_VAR=override_value
```
With a prefix:
```go
cm := jety.NewConfigManager().WithEnvPrefix("MYAPP_")
```
```bash
export MYAPP_PORT=9000
export MYAPP_SERVICES_CLOUD_VAR=override_value
```
**Note**: Environment variables override defaults but config files take highest precedence.
## Migration Guide
### From v0.x to v1.x
#### Breaking Changes
1. **`WriteConfig()` now returns `error`**
```go
// Before
jety.WriteConfig()
// After
if err := jety.WriteConfig(); err != nil {
// handle error
}
// Or if you want to ignore the error:
_ = jety.WriteConfig()
```
2. **Go 1.25.5 minimum required**
Update your Go version or pin to an older jety release.
#### Non-Breaking Improvements
- Getters (`GetBool`, `GetInt`, `GetDuration`) now return zero values instead of panicking on unknown types
- Added `int64` support in `GetInt`, `GetIntSlice`, and `GetDuration`
- Improved env var parsing (handles values containing `=`)
## API
### Configuration
| Function | Description |
| --------------------- | --------------------------------------------- |
| `SetConfigFile(path)` | Set config file path |
| `SetConfigDir(dir)` | Set config directory |
| `SetConfigName(name)` | Set config file name (without extension) |
| `SetConfigType(type)` | Set config type: `"toml"`, `"yaml"`, `"json"` |
| `ReadInConfig()` | Read config file |
| `WriteConfig()` | Write config to file |
### Values
| Function | Description |
| ------------------------ | ------------------------ |
| `Set(key, value)` | Set a value |
| `SetDefault(key, value)` | Set a default value |
| `Get(key)` | Get raw value |
| `GetString(key)` | Get as string |
| `GetInt(key)` | Get as int |
| `GetBool(key)` | Get as bool |
| `GetDuration(key)` | Get as time.Duration |
| `GetStringSlice(key)` | Get as []string |
| `GetIntSlice(key)` | Get as []int |
| `GetStringMap(key)` | Get as map[string]string |
### Environment
| Function | Description |
| ----------------------- | --------------------------------------------------- |
| `WithEnvPrefix(prefix)` | Filter env vars by prefix (strips prefix from keys) |
| `SetEnvPrefix(prefix)` | Set prefix for env var lookups |
## License
See [LICENSE](LICENSE) file.

View File

@@ -1,4 +1,4 @@
package config
package jety
import "time"
@@ -40,8 +40,8 @@ func Set(key string, value any) {
defaultConfigManager.Set(key, value)
}
func WriteConfig() {
defaultConfigManager.WriteConfig()
func WriteConfig() error {
return defaultConfigManager.WriteConfig()
}
func ConfigFileUsed() string {

31
doc.go Normal file
View File

@@ -0,0 +1,31 @@
// Package jety provides configuration management supporting JSON, ENV, TOML, and YAML formats.
//
// It offers viper-like AutomaticEnv functionality with minimal dependencies, allowing
// configuration to be loaded from files and environment variables with automatic merging.
//
// Configuration sources are layered with the following precedence (highest to lowest):
// - Values set via Set() or SetString()/SetBool()
// - Environment variables (optionally filtered by prefix)
// - Values from config file via ReadInConfig()
// - Default values set via SetDefault()
//
// Basic usage:
//
// jety.SetConfigFile("/etc/myapp/config.yaml")
// jety.SetConfigType("yaml")
// jety.SetEnvPrefix("MYAPP_")
// jety.SetDefault("port", 8080)
//
// if err := jety.ReadInConfig(); err != nil {
// log.Fatal(err)
// }
//
// port := jety.GetInt("port")
//
// For multiple independent configurations, create separate ConfigManager instances:
//
// cm := jety.NewConfigManager()
// cm.SetConfigFile("/etc/myapp/config.toml")
// cm.SetConfigType("toml")
// cm.ReadInConfig()
package jety

View File

@@ -1,4 +1,4 @@
package config
package jety
import (
"fmt"
@@ -12,7 +12,7 @@ func (c *ConfigManager) Get(key string) any {
defer c.mutex.RUnlock()
v, ok := c.combinedConfig[strings.ToLower(key)]
if !ok {
v, ok = c.envConfig[strings.ToLower(c.envPrefix+key)]
v, ok = c.envConfig[strings.ToLower(key)]
if !ok {
return nil
}
@@ -25,7 +25,7 @@ func (c *ConfigManager) GetBool(key string) bool {
defer c.mutex.RUnlock()
v, ok := c.combinedConfig[strings.ToLower(key)]
if !ok {
v, ok = c.envConfig[strings.ToLower(c.envPrefix+key)]
v, ok = c.envConfig[strings.ToLower(key)]
if !ok {
return false
}
@@ -35,29 +35,19 @@ func (c *ConfigManager) GetBool(key string) bool {
case bool:
return val
case string:
if strings.ToLower(val) == "true" {
return true
}
return false
return strings.EqualFold(val, "true")
case int:
if val == 0 {
return false
}
return true
case float32, float64:
if val == 0 {
return false
}
return true
return val != 0
case float32:
return val != 0
case float64:
return val != 0
case time.Duration:
return val > 0
case nil:
return false
case time.Duration:
if val == 0 || val < 0 {
return false
}
return true
default:
return val.(bool)
return false
}
}
@@ -66,7 +56,7 @@ func (c *ConfigManager) GetDuration(key string) time.Duration {
defer c.mutex.RUnlock()
v, ok := c.combinedConfig[strings.ToLower(key)]
if !ok {
v, ok = c.envConfig[strings.ToLower(c.envPrefix+key)]
v, ok = c.envConfig[strings.ToLower(key)]
if !ok {
return 0
}
@@ -83,6 +73,8 @@ func (c *ConfigManager) GetDuration(key string) time.Duration {
return d
case int:
return time.Duration(val)
case int64:
return time.Duration(val)
case float32:
return time.Duration(val)
case float64:
@@ -90,8 +82,7 @@ func (c *ConfigManager) GetDuration(key string) time.Duration {
case nil:
return 0
default:
return val.(time.Duration)
return 0
}
}
@@ -100,7 +91,7 @@ func (c *ConfigManager) GetString(key string) string {
defer c.mutex.RUnlock()
v, ok := c.combinedConfig[strings.ToLower(key)]
if !ok {
v, ok = c.envConfig[strings.ToLower(c.envPrefix+key)]
v, ok = c.envConfig[strings.ToLower(key)]
if !ok {
return ""
}
@@ -119,7 +110,7 @@ func (c *ConfigManager) GetStringMap(key string) map[string]any {
defer c.mutex.RUnlock()
v, ok := c.combinedConfig[strings.ToLower(key)]
if !ok {
v, ok = c.envConfig[strings.ToLower(c.envPrefix+key)]
v, ok = c.envConfig[strings.ToLower(key)]
if !ok {
return nil
}
@@ -137,7 +128,7 @@ func (c *ConfigManager) GetStringSlice(key string) []string {
defer c.mutex.RUnlock()
v, ok := c.combinedConfig[strings.ToLower(key)]
if !ok {
v, ok = c.envConfig[strings.ToLower(c.envPrefix+key)]
v, ok = c.envConfig[strings.ToLower(key)]
if !ok {
return nil
}
@@ -145,6 +136,17 @@ func (c *ConfigManager) GetStringSlice(key string) []string {
switch val := v.Value.(type) {
case []string:
return val
case []any:
var ret []string
for _, v := range val {
switch v := v.(type) {
case string:
ret = append(ret, v)
default:
ret = append(ret, fmt.Sprintf("%v", v))
}
}
return ret
default:
return nil
}
@@ -155,7 +157,7 @@ func (c *ConfigManager) GetInt(key string) int {
defer c.mutex.RUnlock()
v, ok := c.combinedConfig[strings.ToLower(key)]
if !ok {
v, ok = c.envConfig[strings.ToLower(c.envPrefix+key)]
v, ok = c.envConfig[strings.ToLower(key)]
if !ok {
return 0
}
@@ -163,6 +165,8 @@ func (c *ConfigManager) GetInt(key string) int {
switch val := v.Value.(type) {
case int:
return val
case int64:
return int(val)
case string:
i, err := strconv.Atoi(val)
if err != nil {
@@ -185,7 +189,7 @@ func (c *ConfigManager) GetIntSlice(key string) []int {
defer c.mutex.RUnlock()
v, ok := c.combinedConfig[strings.ToLower(key)]
if !ok {
v, ok = c.envConfig[strings.ToLower(c.envPrefix+key)]
v, ok = c.envConfig[strings.ToLower(key)]
if !ok {
return nil
}
@@ -193,6 +197,31 @@ func (c *ConfigManager) GetIntSlice(key string) []int {
switch val := v.Value.(type) {
case []int:
return val
case []any:
var ret []int
for _, v := range val {
switch v := v.(type) {
case int:
ret = append(ret, v)
case int64:
ret = append(ret, int(v))
case string:
i, err := strconv.Atoi(v)
if err != nil {
continue
}
ret = append(ret, i)
case float32:
ret = append(ret, int(v))
case float64:
ret = append(ret, int(v))
case nil:
continue
default:
continue
}
}
return ret
default:
return nil
}

4
go.mod
View File

@@ -1,8 +1,8 @@
module github.com/taigrr/jety
go 1.21.3
go 1.26.0
require (
github.com/BurntSushi/toml v1.3.2
github.com/BurntSushi/toml v1.6.0
gopkg.in/yaml.v3 v3.0.1
)

4
go.sum
View File

@@ -1,5 +1,5 @@
github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

95
jety.go
View File

@@ -1,10 +1,12 @@
package config
package jety
import (
"encoding/json"
"errors"
"fmt"
"maps"
"os"
"path/filepath"
"strings"
"sync"
@@ -31,7 +33,6 @@ type (
configPath string
configFileUsed string
configType configType
envPrefix string
mapConfig map[string]ConfigMap
defaultConfig map[string]ConfigMap
envConfig map[string]ConfigMap
@@ -41,7 +42,10 @@ type (
}
)
var ErrConfigFileNotFound = errors.New("config File Not Found")
var (
ErrConfigFileNotFound = errors.New("config file not found")
ErrConfigFileEmpty = errors.New("config file is empty")
)
func NewConfigManager() *ConfigManager {
cm := ConfigManager{}
@@ -49,12 +53,14 @@ func NewConfigManager() *ConfigManager {
cm.mapConfig = make(map[string]ConfigMap)
cm.defaultConfig = make(map[string]ConfigMap)
cm.combinedConfig = make(map[string]ConfigMap)
cm.envPrefix = ""
envSet := os.Environ()
for _, env := range envSet {
kv := strings.Split(env, "=")
lower := strings.ToLower(kv[0])
cm.envConfig[lower] = ConfigMap{Key: kv[0], Value: kv[1]}
key, value, found := strings.Cut(env, "=")
if !found {
continue
}
lower := strings.ToLower(key)
cm.envConfig[lower] = ConfigMap{Key: key, Value: value}
}
return &cm
}
@@ -62,15 +68,16 @@ func NewConfigManager() *ConfigManager {
func (c *ConfigManager) WithEnvPrefix(prefix string) *ConfigManager {
c.mutex.Lock()
defer c.mutex.Unlock()
c.envPrefix = prefix
envSet := os.Environ()
c.envConfig = make(map[string]ConfigMap)
for _, env := range envSet {
kv := strings.Split(env, "=")
if strings.HasPrefix(kv[0], prefix) {
withoutPrefix := strings.TrimPrefix(kv[0], prefix)
key, value, found := strings.Cut(env, "=")
if !found {
continue
}
if withoutPrefix, ok := strings.CutPrefix(key, prefix); ok {
lower := strings.ToLower(withoutPrefix)
c.envConfig[lower] = ConfigMap{Key: withoutPrefix, Value: kv[1]}
c.envConfig[lower] = ConfigMap{Key: withoutPrefix, Value: value}
}
}
return c
@@ -89,8 +96,8 @@ func (c *ConfigManager) UseExplicitDefaults(enable bool) {
}
func (c *ConfigManager) collapse() {
c.mutex.RLock()
defer c.mutex.RUnlock()
c.mutex.Lock()
defer c.mutex.Unlock()
ccm := make(map[string]ConfigMap)
for k, v := range c.defaultConfig {
ccm[k] = v
@@ -98,9 +105,7 @@ func (c *ConfigManager) collapse() {
ccm[k] = c.envConfig[k]
}
}
for k, v := range c.mapConfig {
ccm[k] = v
}
maps.Copy(ccm, c.mapConfig)
c.combinedConfig = ccm
}
@@ -144,6 +149,8 @@ func (c *ConfigManager) WriteConfig() error {
}
func (c *ConfigManager) SetConfigType(configType string) error {
c.mutex.Lock()
defer c.mutex.Unlock()
switch configType {
case "toml":
c.configType = ConfigTypeTOML
@@ -158,14 +165,48 @@ func (c *ConfigManager) SetConfigType(configType string) error {
}
func (c *ConfigManager) SetEnvPrefix(prefix string) {
c.envPrefix = prefix
c.mutex.Lock()
defer c.mutex.Unlock()
// Re-read environment variables, stripping the prefix from matching keys.
// This mirrors WithEnvPrefix behavior so that prefixed env vars are
// accessible by their unprefixed key name.
envSet := os.Environ()
c.envConfig = make(map[string]ConfigMap)
for _, env := range envSet {
key, value, found := strings.Cut(env, "=")
if !found {
continue
}
if withoutPrefix, ok := strings.CutPrefix(key, prefix); ok {
lower := strings.ToLower(withoutPrefix)
c.envConfig[lower] = ConfigMap{Key: withoutPrefix, Value: value}
}
}
}
func (c *ConfigManager) ReadInConfig() error {
c.mutex.Lock()
defer c.mutex.Unlock()
// assume config = map[string]any
confFileData, err := readFile(c.configFileUsed, c.configType)
c.mutex.RLock()
configFile := c.configFileUsed
if configFile == "" && c.configPath != "" && c.configName != "" {
ext := ""
switch c.configType {
case ConfigTypeTOML:
ext = ".toml"
case ConfigTypeYAML:
ext = ".yaml"
case ConfigTypeJSON:
ext = ".json"
}
configFile = filepath.Join(c.configPath, c.configName+ext)
}
configType := c.configType
c.mutex.RUnlock()
if configFile == "" {
return errors.New("no config file specified: use SetConfigFile or SetConfigDir + SetConfigName")
}
confFileData, err := readFile(configFile, configType)
if err != nil {
return err
}
@@ -174,12 +215,22 @@ func (c *ConfigManager) ReadInConfig() error {
lower := strings.ToLower(k)
conf[lower] = ConfigMap{Key: k, Value: v}
}
c.mutex.Lock()
c.mapConfig = conf
c.configFileUsed = configFile
c.mutex.Unlock()
c.collapse()
return nil
}
func readFile(filename string, fileType configType) (map[string]any, error) {
fileData := make(map[string]any)
if d, err := os.Stat(filename); os.IsNotExist(err) {
return nil, ErrConfigFileNotFound
} else if d.Size() == 0 {
return nil, ErrConfigFileEmpty
}
switch fileType {
case ConfigTypeTOML:
_, err := toml.DecodeFile(filename, &fileData)

1349
jety_test.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
package config
package jety
import (
"strings"
@@ -35,10 +35,12 @@ func (c *ConfigManager) SetDefault(key string, value any) {
c.defaultConfig[lower] = ConfigMap{Key: key, Value: value}
if _, ok := c.mapConfig[lower]; !ok {
if envVal, ok := c.envConfig[lower]; ok {
c.mapConfig[lower] = envVal
c.combinedConfig[lower] = envVal
c.mapConfig[lower] = ConfigMap{Key: key, Value: envVal.Value}
c.combinedConfig[lower] = ConfigMap{Key: key, Value: envVal.Value}
} else {
c.combinedConfig[lower] = ConfigMap{Key: key, Value: value}
}
} else {
c.combinedConfig[lower] = c.mapConfig[lower]
}
}