diff --git a/README.md b/README.md index f98ace4..cd0b1c1 100644 --- a/README.md +++ b/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")) diff --git a/gjson_edge_cases_refactored_test.go b/gjson_edge_cases_refactored_test.go new file mode 100644 index 0000000..7fd3fa3 --- /dev/null +++ b/gjson_edge_cases_refactored_test.go @@ -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 + } +} diff --git a/gjson_edge_cases_test.go b/gjson_edge_cases_test.go new file mode 100644 index 0000000..deecddd --- /dev/null +++ b/gjson_edge_cases_test.go @@ -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 + }) + } + }) +} diff --git a/gjson_malformed_test.go b/gjson_malformed_test.go new file mode 100644 index 0000000..c654704 --- /dev/null +++ b/gjson_malformed_test.go @@ -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) + } + }) +} diff --git a/gjson_path_test.go b/gjson_path_test.go new file mode 100644 index 0000000..35557ed --- /dev/null +++ b/gjson_path_test.go @@ -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) + } + }) +} diff --git a/smartconfig.go b/smartconfig.go index 9551113..fa91b51 100644 --- a/smartconfig.go +++ b/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 diff --git a/test_helpers_test.go b/test_helpers_test.go new file mode 100644 index 0000000..1542c6b --- /dev/null +++ b/test_helpers_test.go @@ -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 +}