13 Commits

9 changed files with 1590 additions and 56 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"
@@ -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
}
}
@@ -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
}
}
@@ -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
}
@@ -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 {
@@ -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=

79
jety.go
View File

@@ -1,10 +1,12 @@
package config
package jety
import (
"encoding/json"
"errors"
"fmt"
"maps"
"os"
"path/filepath"
"strings"
"sync"
@@ -41,7 +43,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{}
@@ -52,9 +57,12 @@ func NewConfigManager() *ConfigManager {
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,17 +70,20 @@ 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}
}
}
// Don't set envPrefix since keys are already stripped of prefix
c.envPrefix = ""
return c
}
@@ -89,8 +100,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 +109,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 +153,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 +169,34 @@ func (c *ConfigManager) SetConfigType(configType string) error {
}
func (c *ConfigManager) SetEnvPrefix(prefix string) {
c.mutex.Lock()
defer c.mutex.Unlock()
c.envPrefix = prefix
}
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 +205,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)

1268
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"
@@ -34,11 +34,13 @@ func (c *ConfigManager) SetDefault(key string, value any) {
lower := strings.ToLower(key)
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
if envVal, ok := c.envConfig[lower]; ok {
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]
}
}