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:
parent
6b8c16dd1d
commit
47801ce852
117
README.md
117
README.md
@ -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"))
|
||||
|
292
gjson_edge_cases_refactored_test.go
Normal file
292
gjson_edge_cases_refactored_test.go
Normal 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
393
gjson_edge_cases_test.go
Normal 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
282
gjson_malformed_test.go
Normal 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
325
gjson_path_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
135
smartconfig.go
135
smartconfig.go
@ -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
92
test_helpers_test.go
Normal 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
|
||||
}
|
Loading…
Reference in New Issue
Block a user