Add gjson path support to all typed getters

This change enhances the API to support gjson path syntax for accessing nested configuration values. Users can now use paths like 'server.ssl.enabled' or 'database.replicas.0.host' to directly access nested values without manual navigation.

Key changes:
- All typed getters now support gjson path syntax
- Backward compatibility maintained for top-level keys
- Proper error handling for null values and non-existent paths
- Special float values (Infinity, NaN) handled correctly
- Comprehensive test coverage for edge cases

This makes the API much more intuitive and reduces boilerplate code when working with nested configuration structures.
This commit is contained in:
Jeffrey Paul 2025-07-22 12:24:59 +02:00
parent 6b8c16dd1d
commit 47801ce852
7 changed files with 1551 additions and 85 deletions

117
README.md
View File

@ -24,6 +24,7 @@ A Go library for YAML configuration files with powerful variable interpolation f
- **Multiple Data Sources**: Environment variables, files, cloud secrets (AWS, GCP, Azure), Vault, Consul, K8S, and more
- **Nested Interpolation**: Support for complex variable references like `${ENV:PREFIX_${ENV:SUFFIX}}`
- **JSON5 Support**: JSON resolver supports comments and trailing commas
- **gjson Path Support**: Access nested configuration values using gjson syntax like `server.ssl.enabled` or `database.replicas.0.host`
- **Environment Injection**: Automatically export config values as environment variables
- **Extensible**: Add custom resolvers for your own data sources
- **CLI Tool**: Command-line tool for processing YAML files with interpolation
@ -112,26 +113,22 @@ func main() {
fmt.Printf("Starting %s on port %d (debug: %v)\n", appName, port, debugMode)
// Access nested values
data := config.Data()
// API keys configuration
if apiKeys, ok := data["api_keys"].(map[string]interface{}); ok {
if stripe, ok := apiKeys["stripe"].(string); ok {
fmt.Printf("Stripe API key loaded: %s...\n", stripe[:8])
}
}
// Access nested values using gjson paths
stripeKey, _ := config.GetString("api_keys.stripe")
fmt.Printf("Stripe API key loaded: %s...\n", stripeKey[:8])
// Database configuration
if db, ok := data["database"].(map[string]interface{}); ok {
host, _ := db["host"].(string)
port, _ := db["port"].(int)
fmt.Printf("Database: %s:%d\n", host, port)
}
dbHost, _ := config.GetString("database.host")
dbPort, _ := config.GetInt("database.port")
sslEnabled, _ := config.GetBool("database.ssl.enabled")
fmt.Printf("Database: %s:%d (SSL: %v)\n", dbHost, dbPort, sslEnabled)
// Check loaded services from JSON file
if services, ok := data["services"].(map[string]interface{}); ok {
fmt.Printf("Loaded %d services from JSON config\n", len(services))
services, exists := config.Get("services")
if exists && services != nil {
if servicesMap, ok := services.(map[string]interface{}); ok {
fmt.Printf("Loaded %d services from JSON config\n", len(servicesMap))
}
}
// Environment variables are now available
@ -366,49 +363,49 @@ config, err := smartconfig.NewFromReader(reader)
### Accessing Values
**Important**: The `Get()` method only supports top-level keys. For nested values, you need to navigate the structure manually.
All accessor methods now support gjson path syntax for accessing nested values:
```go
// Get top-level value only
value, exists := config.Get("server") // Returns the entire server map
if !exists {
log.Fatal("server configuration not found")
// Get nested values using gjson paths
host, err := config.GetString("server.host") // "localhost"
port, err := config.GetInt("database.replicas.0.port") // 5433
enabled, err := config.GetBool("server.ssl.enabled") // true
// Get() method also supports gjson paths
value, exists := config.Get("database.primary.credentials")
if exists {
// value is map[string]interface{} with username and password
}
// For nested values, cast and navigate
if serverMap, ok := value.(map[string]interface{}); ok {
if port, ok := serverMap["port"].(int); ok {
fmt.Printf("Port: %d\n", port)
}
}
// Or use typed getters for top-level values
port, err := config.GetInt("port") // Works for top-level
host, err := config.GetString("host") // Works for top-level
// Backward compatibility: top-level keys still work
appName, err := config.GetString("app_name") // Direct top-level access
```
### Typed Getters
All typed getters work with top-level keys only:
All typed getters support gjson path syntax for accessing nested values:
```go
// String values
name, err := config.GetString("app_name")
name, err := config.GetString("app_name") // Top-level
dbHost, err := config.GetString("database.primary.host") // Nested path
// Integer values (works with both int and string values)
port, err := config.GetInt("port")
port, err := config.GetInt("server.port") // Nested
replicaPort, err := config.GetInt("database.replicas.1.port") // Array element
// Unsigned integers
maxConn, err := config.GetUint("max_connections")
maxConn, err := config.GetUint("features.max_connections") // Nested
// Float values
timeout, err := config.GetFloat("timeout_seconds")
timeout, err := config.GetFloat("features.timeout_seconds") // Nested
// Boolean values (works with bool and string "true"/"false")
debug, err := config.GetBool("debug_mode")
debug, err := config.GetBool("features.debug_mode") // Nested
sslEnabled, err := config.GetBool("server.ssl.enabled") // Deep nested
// Byte sizes with human-readable formats ("10GB", "512MiB", etc.)
maxSize, err := config.GetBytes("max_file_size")
maxSize, err := config.GetBytes("features.max_file_size") // Nested
// Get entire config as map
data := config.Data()
@ -416,7 +413,7 @@ data := config.Data()
### Working with Nested Values
Since the API doesn't support dot notation, here's how to work with nested values:
With gjson path support, accessing nested values is now straightforward:
```go
// Given this YAML:
@ -427,18 +424,23 @@ Since the API doesn't support dot notation, here's how to work with nested value
// enabled: true
// cert: /etc/ssl/cert.pem
data := config.Data()
// Direct access using gjson paths
sslEnabled, _ := config.GetBool("server.ssl.enabled") // true
sslCert, _ := config.GetString("server.ssl.cert") // "/etc/ssl/cert.pem"
serverPort, _ := config.GetInt("server.port") // 8080
// Navigate manually
if server, ok := data["server"].(map[string]interface{}); ok {
if ssl, ok := server["ssl"].(map[string]interface{}); ok {
if enabled, ok := ssl["enabled"].(bool); ok {
fmt.Printf("SSL enabled: %v\n", enabled)
}
}
}
// Array access
// database:
// replicas:
// - host: db1.example.com
// port: 5433
// - host: db2.example.com
// port: 5434
// Or unmarshal into a struct
firstReplica, _ := config.GetString("database.replicas.0.host") // "db1.example.com"
secondPort, _ := config.GetInt("database.replicas.1.port") // 5434
// You can still unmarshal into a struct if preferred
type Config struct {
Server struct {
Host string
@ -569,17 +571,14 @@ func main() {
fmt.Printf("Starting %s on port %d (debug: %v)\n", appName, serverPort, debugEnabled)
fmt.Printf("Max upload size: %d bytes\n", maxUploadSize)
// Access nested configuration
data := config.Data()
// Access nested configuration using gjson paths
dbHost, _ := config.GetString("database.primary.host")
dbPort, _ := config.GetInt("database.primary.port")
fmt.Printf("Database: %s:%d\n", dbHost, dbPort)
// Database configuration
if db, ok := data["database"].(map[string]interface{}); ok {
if primary, ok := db["primary"].(map[string]interface{}); ok {
dbHost, _ := primary["host"].(string)
dbPort, _ := primary["port"].(int)
fmt.Printf("Database: %s:%d\n", dbHost, dbPort)
}
}
// Access array elements
replicaHost, _ := config.GetString("database.replica.host")
fmt.Printf("Replica: %s\n", replicaHost)
// Check injected environment variables
fmt.Printf("DATABASE_URL: %s\n", os.Getenv("DATABASE_URL"))

View File

@ -0,0 +1,292 @@
package smartconfig
import (
"testing"
)
func TestGjsonPathEdgeCasesRefactored(t *testing.T) {
config := loadConfig(t, `
empty_string: ""
null_value: null
zero: 0
false_bool: false
empty_map: {}
empty_array: []
special_keys:
"key.with.dots": "dotted key value"
"key with spaces": "spaced key value"
"key[with]brackets": "bracketed key value"
"123": "numeric key"
"": "empty key"
deeply:
nested:
structure:
with:
many:
levels:
value: "deep value"
arrays:
nested:
- - - value: "triple nested array"
mixed:
- name: "first"
items:
- id: 1
- id: 2
- name: "second"
items:
- id: 3
- id: 4
types:
very_large_number: 9223372036854775807
very_small_number: -9223372036854775808
special_values:
inf: .inf
neg_inf: -.inf
nan: .nan
`)
t.Run("Empty and null values", func(t *testing.T) {
// Empty string
val, err := config.GetString("empty_string")
assertNoError(t, err)
assertString(t, val, "")
// Zero value
zero, err := config.GetInt("zero")
assertNoError(t, err)
assertInt(t, zero, 0)
// False boolean
falseBool, err := config.GetBool("false_bool")
assertNoError(t, err)
assertBool(t, falseBool, false)
// Null value should fail for typed getters
_, err = config.GetString("null_value")
assertError(t, err)
_, err = config.GetInt("null_value")
assertError(t, err)
_, err = config.GetBool("null_value")
assertError(t, err)
})
t.Run("Empty collections", func(t *testing.T) {
// Empty map
emptyMap, exists := config.Get("empty_map")
assertExists(t, exists)
m := assertType[map[string]interface{}](t, emptyMap)
if len(m) != 0 {
t.Errorf("empty_map should have 0 elements, has %d", len(m))
}
// Empty array
emptyArray, exists := config.Get("empty_array")
assertExists(t, exists)
a := assertType[[]interface{}](t, emptyArray)
if len(a) != 0 {
t.Errorf("empty_array should have 0 elements, has %d", len(a))
}
})
t.Run("Deep nesting", func(t *testing.T) {
deepValue, err := config.GetString("deeply.nested.structure.with.many.levels.value")
assertNoError(t, err)
assertString(t, deepValue, "deep value")
})
t.Run("Complex arrays", func(t *testing.T) {
// Triple nested array
val, err := config.GetString("arrays.nested.0.0.0.value")
assertNoError(t, err)
assertString(t, val, "triple nested array")
// Mixed structure navigation
id1, err := config.GetInt("arrays.mixed.0.items.0.id")
assertNoError(t, err)
assertInt(t, id1, 1)
id4, err := config.GetInt("arrays.mixed.1.items.1.id")
assertNoError(t, err)
assertInt(t, id4, 4)
})
t.Run("Large numbers", func(t *testing.T) {
largeNum, err := config.GetInt("types.very_large_number")
assertNoError(t, err)
assertInt(t, largeNum, 9223372036854775807)
smallNum, err := config.GetInt("types.very_small_number")
assertNoError(t, err)
assertInt(t, smallNum, -9223372036854775808)
})
t.Run("Special float values", func(t *testing.T) {
// These special float values should be converted to strings
inf, err := config.GetString("special_values.inf")
assertNoError(t, err)
assertString(t, inf, "Infinity")
negInf, err := config.GetString("special_values.neg_inf")
assertNoError(t, err)
assertString(t, negInf, "-Infinity")
nan, err := config.GetString("special_values.nan")
assertNoError(t, err)
assertString(t, nan, "NaN")
})
t.Run("Non-existent paths", func(t *testing.T) {
paths := []string{
"nonexistent",
"nonexistent.nested.key",
"deeply.nonexistent",
"arrays.mixed.999.name",
"arrays.mixed.-1.name",
}
for _, path := range paths {
t.Run(path, func(t *testing.T) {
_, err := config.GetString(path)
assertError(t, err)
_, err = config.GetInt(path)
assertError(t, err)
_, exists := config.Get(path)
assertNotExists(t, exists)
})
}
})
t.Run("Invalid path syntax", func(t *testing.T) {
// These paths have invalid syntax
paths := []string{
"", // Empty path
"...", // Only dots
"key.", // Trailing dot
".key", // Leading dot
"key..value", // Double dots
}
for _, path := range paths {
t.Run(path, func(t *testing.T) {
_, err := config.GetString(path)
assertError(t, err)
_, exists := config.Get(path)
assertNotExists(t, exists)
})
}
})
}
func TestTypeConversions(t *testing.T) {
config := loadConfig(t, `
string_number: "123"
string_bool: "true"
string_float: "45.67"
string_bytes: "1GB"
not_a_number: "abc"
not_a_bool: "yes"
partial_number: "123abc"
object:
key: value
array:
- item1
- item2
`)
t.Run("Valid conversions", func(t *testing.T) {
// String to int
num, err := config.GetInt("string_number")
assertNoError(t, err)
assertInt(t, num, 123)
// String to bool
b, err := config.GetBool("string_bool")
assertNoError(t, err)
assertBool(t, b, true)
// String to float
f, err := config.GetFloat("string_float")
assertNoError(t, err)
assertFloat(t, f, 45.67)
// String to bytes
bytes, err := config.GetBytes("string_bytes")
assertNoError(t, err)
assertUint64(t, bytes, 1000000000) // 1GB in decimal
})
t.Run("Invalid conversions", func(t *testing.T) {
// Invalid string to int
_, err := config.GetInt("not_a_number")
assertError(t, err)
// Invalid string to bool
_, err = config.GetBool("not_a_bool")
assertError(t, err)
// Partial number
_, err = config.GetInt("partial_number")
assertError(t, err)
// Object as string (should fail)
_, err = config.GetString("object")
assertError(t, err)
// Array as int (should fail)
_, err = config.GetInt("array")
assertError(t, err)
})
}
func TestConcurrentAccess(t *testing.T) {
config := loadConfig(t, `
concurrent:
value1: "test1"
value2: "test2"
nested:
value3: "test3"
`)
// Run multiple goroutines accessing the config
done := make(chan bool, 10)
for i := 0; i < 10; i++ {
go func(id int) {
for j := 0; j < 100; j++ {
switch id % 3 {
case 0:
val, err := config.GetString("concurrent.value1")
if err != nil || val != "test1" {
t.Errorf("concurrent access failed: %v, %v", val, err)
}
case 1:
val, err := config.GetString("concurrent.value2")
if err != nil || val != "test2" {
t.Errorf("concurrent access failed: %v, %v", val, err)
}
case 2:
val, err := config.GetString("concurrent.nested.value3")
if err != nil || val != "test3" {
t.Errorf("concurrent access failed: %v, %v", val, err)
}
}
}
done <- true
}(i)
}
// Wait for all goroutines
for i := 0; i < 10; i++ {
<-done
}
}

393
gjson_edge_cases_test.go Normal file
View File

@ -0,0 +1,393 @@
package smartconfig
import (
"fmt"
"strconv"
"strings"
"testing"
)
func TestGjsonPathEdgeCases(t *testing.T) {
yamlContent := `
# Edge case test data
empty_string: ""
null_value: null
zero: 0
false_bool: false
empty_map: {}
empty_array: []
special_keys:
"key.with.dots": "dotted key value"
"key with spaces": "spaced key value"
"key[with]brackets": "bracketed key value"
"key{with}braces": "braced key value"
"123": "numeric key"
"": "empty key"
"key\"with\"quotes": "quoted key"
"key'with'quotes": "single quoted key"
"key\nwith\nnewlines": "newline key"
"key\twith\ttabs": "tab key"
deeply:
nested:
structure:
with:
many:
levels:
value: "deep value"
arrays:
nested:
- - - value: "triple nested array"
mixed:
- name: "first"
items:
- id: 1
- id: 2
- name: "second"
items:
- id: 3
- id: 4
unicode:
"emoji🎉key": "emoji value"
"中文键": "chinese value"
"مفتاح": "arabic value"
"ключ": "russian value"
types:
very_large_number: 9223372036854775807 # max int64
very_small_number: -9223372036854775808 # min int64
float_edge: 1.7976931348623157e+308 # near max float64
tiny_float: 2.2250738585072014e-308 # near min positive float64
special_values:
inf: .inf
neg_inf: -.inf
nan: .nan
inf_string: "Infinity"
neg_inf_string: "-Infinity"
nan_string: "NaN"
`
config, err := NewFromReader(strings.NewReader(yamlContent))
if err != nil {
t.Fatalf("Failed to load config: %v", err)
}
t.Run("Empty and null values", func(t *testing.T) {
// Empty string
val, err := config.GetString("empty_string")
if err != nil || val != "" {
t.Errorf("GetString(empty_string) = %q, %v; want empty string, nil", val, err)
}
// Zero value
zero, err := config.GetInt("zero")
if err != nil || zero != 0 {
t.Errorf("GetInt(zero) = %d, %v; want 0, nil", zero, err)
}
// False boolean
falseBool, err := config.GetBool("false_bool")
if err != nil || falseBool != false {
t.Errorf("GetBool(false_bool) = %v, %v; want false, nil", falseBool, err)
}
// Null value should fail for typed getters
_, err = config.GetString("null_value")
if err == nil {
t.Error("GetString(null_value) should return error for null")
}
})
t.Run("Special character keys", func(t *testing.T) {
// Keys with dots should be accessible with escaped syntax
// Note: gjson doesn't support accessing keys with dots directly
// This is a known limitation
// Empty map
emptyMap, exists := config.Get("empty_map")
if !exists {
t.Error("empty_map should exist")
}
if m, ok := emptyMap.(map[string]interface{}); !ok || len(m) != 0 {
t.Errorf("empty_map should be an empty map, got %v", emptyMap)
}
// Empty array
emptyArray, exists := config.Get("empty_array")
if !exists {
t.Error("empty_array should exist")
}
if a, ok := emptyArray.([]interface{}); !ok || len(a) != 0 {
t.Errorf("empty_array should be an empty array, got %v", emptyArray)
}
})
t.Run("Very deep nesting", func(t *testing.T) {
deepValue, err := config.GetString("deeply.nested.structure.with.many.levels.value")
if err != nil || deepValue != "deep value" {
t.Errorf("Deep nested access failed: %v, %v", deepValue, err)
}
})
t.Run("Complex array access", func(t *testing.T) {
// Triple nested array
val, err := config.GetString("arrays.nested.0.0.0.value")
if err != nil || val != "triple nested array" {
t.Errorf("Triple nested array access = %q, %v; want 'triple nested array', nil", val, err)
}
// Mixed structure navigation
id1, err := config.GetInt("arrays.mixed.0.items.0.id")
if err != nil || id1 != 1 {
t.Errorf("Mixed array access = %d, %v; want 1, nil", id1, err)
}
id4, err := config.GetInt("arrays.mixed.1.items.1.id")
if err != nil || id4 != 4 {
t.Errorf("Mixed array access = %d, %v; want 4, nil", id4, err)
}
})
t.Run("Unicode keys", func(t *testing.T) {
// Unicode keys should work normally
chineseVal, exists := config.Get("unicode.中文键")
if !exists || chineseVal != "chinese value" {
t.Errorf("Unicode key access failed: %v", chineseVal)
}
})
t.Run("Type edge cases", func(t *testing.T) {
// Very large numbers
largeNum, err := config.GetInt("types.very_large_number")
if err != nil {
t.Errorf("GetInt(very_large_number) failed: %v", err)
}
if largeNum != 9223372036854775807 {
t.Errorf("Large number = %d, want 9223372036854775807", largeNum)
}
// Very small numbers
smallNum, err := config.GetInt("types.very_small_number")
if err != nil {
t.Errorf("GetInt(very_small_number) failed: %v", err)
}
if smallNum != -9223372036854775808 {
t.Errorf("Small number = %d, want -9223372036854775808", smallNum)
}
})
t.Run("Special float values", func(t *testing.T) {
// These special float values should be converted to strings
inf, err := config.GetString("special_values.inf")
if err != nil || inf != "Infinity" {
t.Errorf("GetString(inf) = %q, %v; want 'Infinity', nil", inf, err)
}
negInf, err := config.GetString("special_values.neg_inf")
if err != nil || negInf != "-Infinity" {
t.Errorf("GetString(neg_inf) = %q, %v; want '-Infinity', nil", negInf, err)
}
nan, err := config.GetString("special_values.nan")
if err != nil || nan != "NaN" {
t.Errorf("GetString(nan) = %q, %v; want 'NaN', nil", nan, err)
}
})
t.Run("Invalid paths", func(t *testing.T) {
// Various invalid path formats
invalidPaths := []string{
"...", // Only dots
"key.", // Trailing dot
".key", // Leading dot
"key..value", // Double dots
"key..", // Double trailing dots
"key[", // Unclosed bracket
"key]", // Unmatched bracket
// "key.[0]", // Actually valid gjson syntax
"arrays.mixed.-1.name", // Negative array index
"arrays.mixed.999.name", // Out of bounds array index
"", // Empty path
"nonexistent", // Simple non-existent key
"nonexistent.nested.key", // Nested non-existent
}
for _, path := range invalidPaths {
t.Run(path, func(t *testing.T) {
_, err := config.GetString(path)
if err == nil {
t.Errorf("GetString(%q) should return error", path)
}
_, exists := config.Get(path)
if exists {
t.Errorf("Get(%q) should return false for exists", path)
}
})
}
})
t.Run("Type conversion edge cases", func(t *testing.T) {
// Try to get string as various types
strConfig := `
string_number: "123"
string_bool: "true"
string_float: "45.67"
string_bytes: "1GB"
not_a_number: "abc"
not_a_bool: "yes"
partial_number: "123abc"
`
c, _ := NewFromReader(strings.NewReader(strConfig))
// String to int
num, err := c.GetInt("string_number")
if err != nil || num != 123 {
t.Errorf("GetInt(string_number) = %d, %v; want 123, nil", num, err)
}
// String to bool
b, err := c.GetBool("string_bool")
if err != nil || b != true {
t.Errorf("GetBool(string_bool) = %v, %v; want true, nil", b, err)
}
// Invalid conversions
_, err = c.GetInt("not_a_number")
if err == nil {
t.Error("GetInt(not_a_number) should fail")
}
_, err = c.GetBool("not_a_bool")
if err == nil {
t.Error("GetBool(not_a_bool) should fail")
}
_, err = c.GetInt("partial_number")
if err == nil {
t.Error("GetInt(partial_number) should fail")
}
})
t.Run("Gjson special syntax", func(t *testing.T) {
// Test that gjson special syntax doesn't cause issues
dangerousPaths := []string{
"*", // Wildcard
"special_keys.#", // Array length operator
"special_keys.@reverse", // Modifier
"special_keys|@pretty", // Pipe operator
"arrays.mixed.#.name", // Multi-value syntax
"arrays.mixed.[0,1]", // Union syntax
}
for _, path := range dangerousPaths {
t.Run(path, func(t *testing.T) {
// These should either work correctly or fail gracefully
_, _ = config.GetString(path)
_, _ = config.Get(path)
// We're just ensuring no panic occurs
})
}
})
}
func TestGjsonPathSecurity(t *testing.T) {
yamlContent := `
sensitive:
password: "secret123"
api_key: "sk-1234567890"
public:
version: "1.0.0"
`
config, err := NewFromReader(strings.NewReader(yamlContent))
if err != nil {
t.Fatalf("Failed to load config: %v", err)
}
t.Run("Path traversal attempts", func(t *testing.T) {
// Ensure path traversal doesn't work
traversalPaths := []string{
"../sensitive/password",
"public/../sensitive/password",
"./sensitive/password",
"public/../../sensitive/password",
}
for _, path := range traversalPaths {
t.Run(path, func(t *testing.T) {
// These should not access sensitive data through traversal
val, _ := config.GetString(path)
if val == "secret123" {
t.Errorf("Path traversal %q should not access sensitive data", path)
}
})
}
})
}
func TestGjsonPerformanceEdgeCases(t *testing.T) {
// Test with large configurations
var largeYaml strings.Builder
largeYaml.WriteString("large_array:\n")
for i := 0; i < 1000; i++ {
largeYaml.WriteString(" - id: ")
largeYaml.WriteString(strconv.Itoa(i))
largeYaml.WriteString("\n")
}
largeYaml.WriteString("large_map:\n")
for i := 0; i < 100; i++ {
largeYaml.WriteString(fmt.Sprintf(" key%d: value%d\n", i, i))
}
config, err := NewFromReader(strings.NewReader(largeYaml.String()))
if err != nil {
t.Fatalf("Failed to load large config: %v", err)
}
// Access various elements
_, err = config.GetString("large_array.999.id")
if err != nil {
t.Errorf("Failed to access last array element: %v", err)
}
_, err = config.GetString("large_map.key99")
if err != nil {
t.Errorf("Failed to access map element: %v", err)
}
}
func TestResolverArgumentValidation(t *testing.T) {
yamlContent := `
test: value
`
config, err := NewFromReader(strings.NewReader(yamlContent))
if err != nil {
t.Fatalf("Failed to load config: %v", err)
}
t.Run("Path with interpolation syntax", func(t *testing.T) {
// Paths that look like interpolation should be handled correctly
suspiciousPaths := []string{
"${ENV:PATH}", // Interpolation syntax in path
"test${ENV:SUFFIX}", // Partial interpolation
"${test}", // Brace syntax
"$test", // Dollar sign
"test$", // Trailing dollar
}
for _, path := range suspiciousPaths {
t.Run(path, func(t *testing.T) {
_, _ = config.GetString(path)
// Should not panic or cause issues
})
}
})
}

282
gjson_malformed_test.go Normal file
View File

@ -0,0 +1,282 @@
package smartconfig
import (
"strings"
"testing"
)
func TestMalformedDataHandling(t *testing.T) {
t.Run("Config with circular references", func(t *testing.T) {
// YAML doesn't support circular references directly, but we can test
// configurations that might cause issues during JSON conversion
yamlContent := `
a: &anchor
b: *anchor
c: test
`
config, err := NewFromReader(strings.NewReader(yamlContent))
if err != nil {
// It's ok if this fails, we just want to ensure no panic
return
}
// Try to access the circular structure
_, _ = config.GetString("a.b.c")
_, _ = config.Get("a.b.b.b.b")
// Should handle gracefully without stack overflow
})
t.Run("Very long paths", func(t *testing.T) {
yamlContent := `
root:
level1:
level2:
level3:
level4:
level5:
value: "deep"
`
config, err := NewFromReader(strings.NewReader(yamlContent))
if err != nil {
t.Fatalf("Failed to load config: %v", err)
}
// Build a very long path
longPath := "root"
for i := 0; i < 100; i++ {
longPath += ".level1"
}
// Should handle gracefully
_, _ = config.GetString(longPath)
// Extremely long path
veryLongPath := strings.Repeat("a.", 10000) + "b"
_, _ = config.GetString(veryLongPath)
})
t.Run("Special YAML values", func(t *testing.T) {
yamlContent := `
special:
yes_value: yes
no_value: no
on_value: on
off_value: off
tilde: ~
empty:
quoted_yes: "yes"
quoted_no: "no"
multiline: |
line1
line2
line3
folded: >
this is
a folded
string
`
config, err := NewFromReader(strings.NewReader(yamlContent))
if err != nil {
t.Fatalf("Failed to load config: %v", err)
}
// YAML's "yes" and "no" are parsed as strings by the YAML parser
// Go's strconv.ParseBool doesn't handle these, so they should fail
_, err = config.GetBool("special.yes_value")
if err == nil {
t.Error("GetBool(yes_value) should fail - 'yes' is not a valid Go boolean")
}
_, err = config.GetBool("special.no_value")
if err == nil {
t.Error("GetBool(no_value) should fail - 'no' is not a valid Go boolean")
}
// But we should be able to get them as strings
yesStr, err := config.GetString("special.yes_value")
if err != nil || yesStr != "yes" {
t.Errorf("GetString(yes_value) = %q, %v; want 'yes', nil", yesStr, err)
}
// Same for "on" and "off"
_, err = config.GetBool("special.on_value")
if err == nil {
t.Error("GetBool(on_value) should fail - 'on' is not a valid Go boolean")
}
_, err = config.GetBool("special.off_value")
if err == nil {
t.Error("GetBool(off_value) should fail - 'off' is not a valid Go boolean")
}
// Quoted versions should be strings
quotedYes, err := config.GetString("special.quoted_yes")
if err != nil || quotedYes != "yes" {
t.Errorf("GetString(quoted_yes) = %q, %v; want 'yes', nil", quotedYes, err)
}
// Multiline strings
multiline, err := config.GetString("special.multiline")
if err != nil || !strings.Contains(multiline, "line1") {
t.Errorf("Multiline string handling failed: %q", multiline)
}
})
t.Run("Invalid type conversions", func(t *testing.T) {
yamlContent := `
data:
object:
key: value
array:
- item1
- item2
string: "hello"
number: 42
boolean: true
`
config, err := NewFromReader(strings.NewReader(yamlContent))
if err != nil {
t.Fatalf("Failed to load config: %v", err)
}
// Try to get object as string
_, err = config.GetString("data.object")
if err == nil {
t.Error("GetString on object should fail")
}
// Try to get array as int
_, err = config.GetInt("data.array")
if err == nil {
t.Error("GetInt on array should fail")
}
// Try to get string as bytes (should work if valid)
_, err = config.GetBytes("data.string")
if err == nil {
t.Error("GetBytes on non-size string should fail")
}
})
t.Run("JSON marshaling edge cases", func(t *testing.T) {
yamlContent := `
tricky:
large_int: 18446744073709551615 # max uint64
float_like_int: 1.0
scientific: 1e10
hex: 0xFF # YAML treats as integer 255
octal: 0o777 # YAML treats as integer 511
binary: 0b1111 # YAML treats as integer 15
date: 2023-12-25
datetime: 2023-12-25T10:30:00Z
`
config, err := NewFromReader(strings.NewReader(yamlContent))
if err != nil {
// Some YAML parsers might not support all these formats
t.Skipf("YAML parser doesn't support test formats: %v", err)
}
// Large integers might lose precision in JSON
_, _ = config.GetString("tricky.large_int")
// Float that looks like int
floatInt, err := config.GetFloat("tricky.float_like_int")
if err != nil {
t.Errorf("GetFloat(float_like_int) failed: %v", err)
}
if floatInt != 1.0 {
t.Errorf("float_like_int = %f, want 1.0", floatInt)
}
// Scientific notation
sci, err := config.GetFloat("tricky.scientific")
if err != nil {
t.Errorf("GetFloat(scientific) failed: %v", err)
}
if sci != 1e10 {
t.Errorf("scientific = %f, want 1e10", sci)
}
})
t.Run("Concurrent access", func(t *testing.T) {
yamlContent := `
concurrent:
value1: "test1"
value2: "test2"
nested:
value3: "test3"
`
config, err := NewFromReader(strings.NewReader(yamlContent))
if err != nil {
t.Fatalf("Failed to load config: %v", err)
}
// Run multiple goroutines accessing the config
done := make(chan bool, 10)
for i := 0; i < 10; i++ {
go func(id int) {
for j := 0; j < 100; j++ {
switch id % 3 {
case 0:
_, _ = config.GetString("concurrent.value1")
case 1:
_, _ = config.GetString("concurrent.value2")
case 2:
_, _ = config.GetString("concurrent.nested.value3")
}
}
done <- true
}(i)
}
// Wait for all goroutines
for i := 0; i < 10; i++ {
<-done
}
})
}
func TestGjsonPathNormalization(t *testing.T) {
yamlContent := `
data:
value: "test"
array:
- first
- second
`
config, err := NewFromReader(strings.NewReader(yamlContent))
if err != nil {
t.Fatalf("Failed to load config: %v", err)
}
t.Run("Different path formats", func(t *testing.T) {
// These should all access the same value
paths := []string{
"data.value",
"data.value", // With spaces (trimmed in real usage)
"data\\.value", // Escaped dot (gjson might interpret differently)
}
for _, path := range paths {
t.Run(path, func(t *testing.T) {
trimmedPath := strings.TrimSpace(path)
_, _ = config.GetString(trimmedPath)
})
}
})
t.Run("Array access formats", func(t *testing.T) {
// Different ways to access array elements
val1, err := config.GetString("data.array.0")
if err != nil || val1 != "first" {
t.Errorf("Array access with .0 failed: %v, %v", val1, err)
}
// gjson also supports [index] syntax
// but our YAML structure uses .index
val2, err := config.GetString("data.array.1")
if err != nil || val2 != "second" {
t.Errorf("Array access with .1 failed: %v, %v", val2, err)
}
})
}

325
gjson_path_test.go Normal file
View File

@ -0,0 +1,325 @@
package smartconfig
import (
"strings"
"testing"
)
func TestGjsonPathSupport(t *testing.T) {
yamlContent := `
server:
host: localhost
port: 8080
ssl:
enabled: true
cert: /etc/ssl/cert.pem
database:
primary:
host: db1.example.com
port: 5432
credentials:
username: admin
password: secret123
replicas:
- host: db2.example.com
port: 5433
- host: db3.example.com
port: 5434
features:
new_ui: true
rate_limiting: false
max_connections: 100
timeout_seconds: 30.5
max_file_size: 10MB
metadata:
version: 1.2.3
build: 12345
`
config, err := NewFromReader(strings.NewReader(yamlContent))
if err != nil {
t.Fatalf("Failed to load config: %v", err)
}
t.Run("GetString with gjson paths", func(t *testing.T) {
tests := []struct {
path string
expected string
}{
{"server.host", "localhost"},
{"server.ssl.cert", "/etc/ssl/cert.pem"},
{"database.primary.host", "db1.example.com"},
{"database.primary.credentials.username", "admin"},
{"database.replicas.0.host", "db2.example.com"},
{"database.replicas.1.host", "db3.example.com"},
{"metadata.version", "1.2.3"},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
result, err := config.GetString(tt.path)
if err != nil {
t.Errorf("GetString(%q) failed: %v", tt.path, err)
return
}
if result != tt.expected {
t.Errorf("GetString(%q) = %q, want %q", tt.path, result, tt.expected)
}
})
}
})
t.Run("GetInt with gjson paths", func(t *testing.T) {
tests := []struct {
path string
expected int
}{
{"server.port", 8080},
{"database.primary.port", 5432},
{"database.replicas.0.port", 5433},
{"database.replicas.1.port", 5434},
{"features.max_connections", 100},
{"metadata.build", 12345},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
result, err := config.GetInt(tt.path)
if err != nil {
t.Errorf("GetInt(%q) failed: %v", tt.path, err)
return
}
if result != tt.expected {
t.Errorf("GetInt(%q) = %d, want %d", tt.path, result, tt.expected)
}
})
}
})
t.Run("GetBool with gjson paths", func(t *testing.T) {
tests := []struct {
path string
expected bool
}{
{"server.ssl.enabled", true},
{"features.new_ui", true},
{"features.rate_limiting", false},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
result, err := config.GetBool(tt.path)
if err != nil {
t.Errorf("GetBool(%q) failed: %v", tt.path, err)
return
}
if result != tt.expected {
t.Errorf("GetBool(%q) = %v, want %v", tt.path, result, tt.expected)
}
})
}
})
t.Run("GetFloat with gjson paths", func(t *testing.T) {
tests := []struct {
path string
expected float64
}{
{"features.timeout_seconds", 30.5},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
result, err := config.GetFloat(tt.path)
if err != nil {
t.Errorf("GetFloat(%q) failed: %v", tt.path, err)
return
}
if result != tt.expected {
t.Errorf("GetFloat(%q) = %f, want %f", tt.path, result, tt.expected)
}
})
}
})
t.Run("GetBytes with gjson paths", func(t *testing.T) {
tests := []struct {
path string
expected uint64
}{
{"features.max_file_size", 10000000}, // 10MB (decimal, as parsed by humanize)
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
result, err := config.GetBytes(tt.path)
if err != nil {
t.Errorf("GetBytes(%q) failed: %v", tt.path, err)
return
}
if result != tt.expected {
t.Errorf("GetBytes(%q) = %d, want %d", tt.path, result, tt.expected)
}
})
}
})
t.Run("Get with gjson paths", func(t *testing.T) {
// Test array access
value, exists := config.Get("database.replicas")
if !exists {
t.Error("Get(database.replicas) should exist")
return
}
replicas, ok := value.([]interface{})
if !ok {
t.Errorf("Get(database.replicas) should return []interface{}, got %T", value)
return
}
if len(replicas) != 2 {
t.Errorf("Expected 2 replicas, got %d", len(replicas))
}
// Test nested object access
value, exists = config.Get("database.primary.credentials")
if !exists {
t.Error("Get(database.primary.credentials) should exist")
return
}
creds, ok := value.(map[string]interface{})
if !ok {
t.Errorf("Get(database.primary.credentials) should return map[string]interface{}, got %T", value)
return
}
if creds["username"] != "admin" {
t.Errorf("Expected username=admin, got %v", creds["username"])
}
})
t.Run("Non-existent paths", func(t *testing.T) {
_, err := config.GetString("server.nonexistent")
if err == nil {
t.Error("GetString(server.nonexistent) should return error")
}
_, err = config.GetInt("database.nonexistent.port")
if err == nil {
t.Error("GetInt(database.nonexistent.port) should return error")
}
_, exists := config.Get("features.nonexistent")
if exists {
t.Error("Get(features.nonexistent) should not exist")
}
})
t.Run("Backward compatibility", func(t *testing.T) {
// Test that top-level keys still work
yamlContent := `
app_name: TestApp
port: 9090
debug: true
timeout: 45.5
max_size: 50MB
`
config, err := NewFromReader(strings.NewReader(yamlContent))
if err != nil {
t.Fatalf("Failed to load config: %v", err)
}
// Test direct top-level access
name, err := config.GetString("app_name")
if err != nil || name != "TestApp" {
t.Errorf("GetString(app_name) = %q, %v; want TestApp, nil", name, err)
}
port, err := config.GetInt("port")
if err != nil || port != 9090 {
t.Errorf("GetInt(port) = %d, %v; want 9090, nil", port, err)
}
debug, err := config.GetBool("debug")
if err != nil || !debug {
t.Errorf("GetBool(debug) = %v, %v; want true, nil", debug, err)
}
timeout, err := config.GetFloat("timeout")
if err != nil || timeout != 45.5 {
t.Errorf("GetFloat(timeout) = %f, %v; want 45.5, nil", timeout, err)
}
maxSize, err := config.GetBytes("max_size")
expectedSize := uint64(50000000) // 50MB (decimal, as parsed by humanize)
if err != nil || maxSize != expectedSize {
t.Errorf("GetBytes(max_size) = %d, %v; want %d, nil", maxSize, err, expectedSize)
}
})
}
func TestGjsonAdvancedPaths(t *testing.T) {
yamlContent := `
users:
- name: Alice
age: 30
roles:
- admin
- developer
- name: Bob
age: 25
roles:
- developer
- name: Charlie
age: 35
roles:
- manager
- admin
settings:
features:
ui:
theme: dark
language: en
api:
rate_limit: 1000
timeout: 30
`
config, err := NewFromReader(strings.NewReader(yamlContent))
if err != nil {
t.Fatalf("Failed to load config: %v", err)
}
t.Run("Array element access", func(t *testing.T) {
// Access specific user
name, err := config.GetString("users.1.name")
if err != nil || name != "Bob" {
t.Errorf("GetString(users.1.name) = %q, %v; want Bob, nil", name, err)
}
age, err := config.GetInt("users.2.age")
if err != nil || age != 35 {
t.Errorf("GetInt(users.2.age) = %d, %v; want 35, nil", age, err)
}
// Access role in array
role, err := config.GetString("users.0.roles.0")
if err != nil || role != "admin" {
t.Errorf("GetString(users.0.roles.0) = %q, %v; want admin, nil", role, err)
}
})
t.Run("Deep nested access", func(t *testing.T) {
theme, err := config.GetString("settings.features.ui.theme")
if err != nil || theme != "dark" {
t.Errorf("GetString(settings.features.ui.theme) = %q, %v; want dark, nil", theme, err)
}
rateLimit, err := config.GetInt("settings.features.api.rate_limit")
if err != nil || rateLimit != 1000 {
t.Errorf("GetInt(settings.features.api.rate_limit) = %d, %v; want 1000, nil", rateLimit, err)
}
})
}

View File

@ -1,14 +1,17 @@
package smartconfig
import (
"encoding/json"
"fmt"
"io"
"math"
"os"
"regexp"
"strconv"
"strings"
"github.com/dustin/go-humanize"
"github.com/tidwall/gjson"
"gopkg.in/yaml.v3"
)
@ -186,22 +189,90 @@ func (c *Config) injectEnvironment() error {
return nil
}
// Get retrieves a value from the configuration using dot notation.
// Get retrieves a value from the configuration using gjson path syntax.
// For example:
// - "database.host" retrieves config.database.host
// - "servers.0.name" retrieves the name of the first server in a list
//
// Returns the value and true if found, nil and false if not found.
func (c *Config) Get(key string) (interface{}, bool) {
return c.data[key], true
// Try direct key lookup first for backward compatibility
if value, exists := c.data[key]; exists {
return value, true
}
// If not found, try gjson path
value, err := c.getValueByPath(key)
if err != nil {
return nil, false
}
return value, true
}
// GetString retrieves a string value from the configuration.
// getValueByPath retrieves a value using gjson path syntax.
// It converts the internal data to JSON and uses gjson to extract the value.
func (c *Config) getValueByPath(path string) (interface{}, error) {
// Clean the data before JSON marshaling to handle special float values
cleanedData := c.cleanDataForJSON(c.data)
// Convert data to JSON
jsonBytes, err := json.Marshal(cleanedData)
if err != nil {
return nil, fmt.Errorf("failed to marshal config to JSON: %w", err)
}
// Use gjson to get the value
result := gjson.GetBytes(jsonBytes, path)
if !result.Exists() {
return nil, fmt.Errorf("key %s not found", path)
}
// Check if the value is null
if result.Type == gjson.Null {
return nil, fmt.Errorf("key %s has null value", path)
}
// Return the underlying value
return result.Value(), nil
}
// cleanDataForJSON recursively cleans data to handle special float values
// that cannot be marshaled to JSON (Inf, -Inf, NaN)
func (c *Config) cleanDataForJSON(data interface{}) interface{} {
switch v := data.(type) {
case map[string]interface{}:
cleaned := make(map[string]interface{})
for key, value := range v {
cleaned[key] = c.cleanDataForJSON(value)
}
return cleaned
case []interface{}:
cleaned := make([]interface{}, len(v))
for i, value := range v {
cleaned[i] = c.cleanDataForJSON(value)
}
return cleaned
case float64:
// Handle special float values
if math.IsInf(v, 1) {
return "Infinity"
} else if math.IsInf(v, -1) {
return "-Infinity"
} else if math.IsNaN(v) {
return "NaN"
}
return v
default:
return v
}
}
// GetString retrieves a string value from the configuration using gjson path syntax.
// Returns an error if the key doesn't exist. Numeric values are converted to strings.
func (c *Config) GetString(key string) (string, error) {
value, ok := c.data[key]
if !ok {
return "", fmt.Errorf("key %s not found", key)
value, err := c.getValueByPath(key)
if err != nil {
return "", err
}
// Try direct string conversion first
@ -209,16 +280,28 @@ func (c *Config) GetString(key string) (string, error) {
return strValue, nil
}
// Don't allow complex types (maps, slices) to be converted to string
switch value.(type) {
case map[string]interface{}, []interface{}:
return "", fmt.Errorf("cannot convert complex type to string for key %s", key)
}
// Convert other types to string
return fmt.Sprintf("%v", value), nil
}
// GetInt retrieves an integer value from the configuration.
// GetInt retrieves an integer value from the configuration using gjson path syntax.
// Returns an error if the key doesn't exist or if the value cannot be converted to an integer.
func (c *Config) GetInt(key string) (int, error) {
value, ok := c.data[key]
if !ok {
return 0, fmt.Errorf("key %s not found", key)
value, err := c.getValueByPath(key)
if err != nil {
return 0, err
}
// Don't allow complex types
switch value.(type) {
case map[string]interface{}, []interface{}:
return 0, fmt.Errorf("cannot convert complex type to integer for key %s", key)
}
// Try direct int conversion first
@ -243,12 +326,12 @@ func (c *Config) GetInt(key string) (int, error) {
return 0, fmt.Errorf("cannot convert value of type %T to integer", value)
}
// GetUint retrieves an unsigned integer value from the configuration.
// GetUint retrieves an unsigned integer value from the configuration using gjson path syntax.
// Returns an error if the key doesn't exist or if the value cannot be converted to a uint.
func (c *Config) GetUint(key string) (uint, error) {
value, ok := c.data[key]
if !ok {
return 0, fmt.Errorf("key %s not found", key)
value, err := c.getValueByPath(key)
if err != nil {
return 0, err
}
// Try direct uint conversion first
@ -284,12 +367,12 @@ func (c *Config) GetUint(key string) (uint, error) {
return 0, fmt.Errorf("cannot convert value of type %T to uint", value)
}
// GetFloat retrieves a float64 value from the configuration.
// GetFloat retrieves a float64 value from the configuration using gjson path syntax.
// Returns an error if the key doesn't exist or if the value cannot be converted to a float64.
func (c *Config) GetFloat(key string) (float64, error) {
value, ok := c.data[key]
if !ok {
return 0, fmt.Errorf("key %s not found", key)
value, err := c.getValueByPath(key)
if err != nil {
return 0, err
}
// Try direct float64 conversion first
@ -319,12 +402,12 @@ func (c *Config) GetFloat(key string) (float64, error) {
return 0, fmt.Errorf("cannot convert value of type %T to float", value)
}
// GetBool retrieves a boolean value from the configuration.
// GetBool retrieves a boolean value from the configuration using gjson path syntax.
// Returns an error if the key doesn't exist or if the value cannot be converted to a boolean.
func (c *Config) GetBool(key string) (bool, error) {
value, ok := c.data[key]
if !ok {
return false, fmt.Errorf("key %s not found", key)
value, err := c.getValueByPath(key)
if err != nil {
return false, err
}
// Try direct bool conversion first
@ -353,13 +436,13 @@ func (c *Config) GetBool(key string) (bool, error) {
return false, fmt.Errorf("cannot convert value of type %T to boolean", value)
}
// GetBytes retrieves a byte size value from the configuration.
// GetBytes retrieves a byte size value from the configuration using gjson path syntax.
// Supports human-readable formats like "10G", "20KiB", "25TB", etc.
// Returns the size in bytes as uint64.
func (c *Config) GetBytes(key string) (uint64, error) {
value, ok := c.data[key]
if !ok {
return 0, fmt.Errorf("key %s not found", key)
value, err := c.getValueByPath(key)
if err != nil {
return 0, err
}
// Try direct numeric conversions first

92
test_helpers_test.go Normal file
View File

@ -0,0 +1,92 @@
package smartconfig
import (
"strings"
"testing"
)
// Helper functions for common test assertions
func assertString(t *testing.T, got, want string) {
t.Helper()
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func assertInt(t *testing.T, got, want int) {
t.Helper()
if got != want {
t.Errorf("got %d, want %d", got, want)
}
}
func assertFloat(t *testing.T, got, want float64) {
t.Helper()
if got != want {
t.Errorf("got %f, want %f", got, want)
}
}
func assertBool(t *testing.T, got, want bool) {
t.Helper()
if got != want {
t.Errorf("got %v, want %v", got, want)
}
}
func assertUint64(t *testing.T, got, want uint64) {
t.Helper()
if got != want {
t.Errorf("got %d, want %d", got, want)
}
}
func assertError(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Error("expected error, got nil")
}
}
func assertNoError(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}
func assertExists(t *testing.T, exists bool) {
t.Helper()
if !exists {
t.Error("expected to exist, but doesn't")
}
}
func assertNotExists(t *testing.T, exists bool) {
t.Helper()
if exists {
t.Error("expected not to exist, but does")
}
}
func assertType[T any](t *testing.T, v interface{}) T {
t.Helper()
typed, ok := v.(T)
if !ok {
var zero T
t.Errorf("expected type %T, got %T", zero, v)
return zero
}
return typed
}
// loadConfig is a helper to load config from YAML string
func loadConfig(t *testing.T, yamlContent string) *Config {
t.Helper()
config, err := NewFromReader(strings.NewReader(yamlContent))
if err != nil {
t.Fatalf("Failed to load config: %v", err)
}
return config
}