Add JSON5 support, --json CLI flag, typed getters, and improve YAML quoting
- Updated JSON resolver to use gjson library for JSON5 support (comments, trailing commas) - Added --json flag to CLI tool for JSON output format - Added typed getter methods: GetInt(), GetUint(), GetFloat(), GetBool(), GetBytes() - GetBytes() supports human-readable formats like "10G", "20KiB" via go-humanize - Fixed YAML interpolation to always quote output values for safety - Updated README to clarify YAML-only input and automatic quoting behavior - Added .gitignore to exclude compiled binary - Fixed test config files to use unquoted interpolation syntax - Updated tests to expect quoted interpolation output
This commit is contained in:
parent
8a38afba5e
commit
3c3732f033
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/smartconfig
|
168
README.md
168
README.md
@ -1,8 +1,11 @@
|
||||
# smartconfig
|
||||
|
||||
smartconfig is a go library that reads a configuration file from the
|
||||
filesystem and interpolates variables in it from several sources, then
|
||||
parses the config file as yaml.
|
||||
smartconfig is a go library that reads YAML configuration files from the
|
||||
filesystem and interpolates variables in them from several sources, then
|
||||
parses the resulting YAML.
|
||||
|
||||
**Important:** smartconfig only supports valid YAML files as input. The file
|
||||
must be valid YAML syntax before interpolation occurs.
|
||||
|
||||
This was inspired by Ryan Smith and one of his config file formats/parsers.
|
||||
It struck me as very clever and I wanted to begin using it.
|
||||
@ -28,39 +31,79 @@ to the environment of the process that reads the config file. This allows a
|
||||
config file to serve as a bridge between fancy backend secret management
|
||||
services and "traditional" configuration via env vars.
|
||||
|
||||
## Important: Automatic Quoting
|
||||
|
||||
All interpolated values are automatically quoted and escaped for YAML safety.
|
||||
This means you should NOT wrap interpolations in quotes:
|
||||
|
||||
```yaml
|
||||
# Correct - no quotes needed
|
||||
password: ${ENV:DB_PASSWORD}
|
||||
enabled: ${ENV:FEATURE_ENABLED}
|
||||
|
||||
# Incorrect - will result in double-quoted values
|
||||
password: "${ENV:DB_PASSWORD}"
|
||||
```
|
||||
|
||||
**Note:** ALL interpolated values are output as quoted strings, regardless of
|
||||
their content. This means:
|
||||
|
||||
1. YAML keywords like `no`, `yes`, `true`, `false`, `on`, `off`, etc. will
|
||||
always be treated as strings, never as boolean values
|
||||
2. Numbers will always be treated as strings, never as numeric values
|
||||
|
||||
```yaml
|
||||
# If ENV:FEATURE_ENABLED is "true", this will be the string "true", not boolean true
|
||||
enabled: ${ENV:FEATURE_ENABLED}
|
||||
|
||||
# If ENV:DEBUG_MODE is "no", this will be the string "no", not boolean false
|
||||
debug: ${ENV:DEBUG_MODE}
|
||||
|
||||
# If ENV:PORT is "8080", this will be the string "8080", not the number 8080
|
||||
port: ${ENV:PORT}
|
||||
|
||||
# If ENV:TIMEOUT is "30.5", this will be the string "30.5", not the float 30.5
|
||||
timeout: ${ENV:TIMEOUT}
|
||||
```
|
||||
|
||||
This design choice prevents ambiguity and parsing errors, ensuring consistent
|
||||
behavior across all interpolated values. Use the typed getter methods (GetInt,
|
||||
GetFloat, GetBool, etc.) to convert string values to their appropriate types
|
||||
when accessing configuration values.
|
||||
|
||||
# Usage
|
||||
|
||||
```yaml
|
||||
# config.yaml
|
||||
|
||||
name: ${ENV:APPLICATION_NAME}
|
||||
host: ${EXEC:"hostname -s"}
|
||||
host: ${EXEC:hostname -s}
|
||||
port: ${ENV:PORT}
|
||||
|
||||
vhost:
|
||||
tls_sni_host: "${JSON:/etc/config/hosts.json:'.tls_sni_host[0]'}"
|
||||
tls_sni_host: ${JSON:/etc/config/hosts.json:tls_sni_host.0}
|
||||
|
||||
machine:
|
||||
id: "${FILE:/etc/machine-id}"
|
||||
temperature: "${FILE:/sys/class/thermal/thermal_zone0/temp}"
|
||||
num_cpus: "${EXEC:nproc}"
|
||||
id: ${FILE:/etc/machine-id}
|
||||
temperature: ${FILE:/sys/class/thermal/thermal_zone0/temp}
|
||||
num_cpus: ${EXEC:nproc}
|
||||
|
||||
api:
|
||||
root_user: "admin"
|
||||
root_password: "${AWSSM:root_password}"
|
||||
root_password: ${AWSSM:root_password}
|
||||
|
||||
db:
|
||||
host: "${GCPSM:${ENV:APPLICATION_NAME}_DB_HOST}"
|
||||
user: "${GCPSM:${ENV:APPLICATION_NAME}_DB_USER}"
|
||||
password: "${GCPSM:${ENV:APPLICATION_NAME}_DB_PASSWORD}"
|
||||
host: ${GCPSM:${ENV:APPLICATION_NAME}_DB_HOST}
|
||||
user: ${GCPSM:${ENV:APPLICATION_NAME}_DB_USER}
|
||||
password: ${GCPSM:${ENV:APPLICATION_NAME}_DB_PASSWORD}
|
||||
|
||||
external:
|
||||
google_api_key: "${CONSUL:secret:google_api_key}"
|
||||
twilio_api_key: "${VAULT:secret:twilio_api_key}"
|
||||
google_api_key: ${CONSUL:secret:google_api_key}
|
||||
twilio_api_key: ${VAULT:secret:twilio_api_key}
|
||||
|
||||
env:
|
||||
ENCRYPTION_PUBLIC_KEY: "${EXEC:secret get myapp/encryption_public_key}"
|
||||
SLACK_WEBHOOK_URL: "${FILE:/etc/config/slack_webhook_url.txt}"
|
||||
ENCRYPTION_PUBLIC_KEY: ${EXEC:secret get myapp/encryption_public_key}
|
||||
SLACK_WEBHOOK_URL: ${FILE:/etc/config/slack_webhook_url.txt}
|
||||
```
|
||||
|
||||
# Supported Providers
|
||||
@ -74,7 +117,7 @@ env:
|
||||
* AZURESM - Azure Key Vault
|
||||
* K8SS - Kubernetes Secrets
|
||||
* FILE - read from a file
|
||||
* JSON - read from a JSON file (supports json5)
|
||||
* JSON - read from a JSON file (supports JSON5 syntax including comments and trailing commas)
|
||||
* YAML - read from a YAML file
|
||||
* ETCD - etcd key-value store
|
||||
|
||||
@ -173,6 +216,62 @@ if err != nil {
|
||||
}
|
||||
```
|
||||
|
||||
### Get Integer Value
|
||||
|
||||
```go
|
||||
// Returns (int, error)
|
||||
port, err := config.GetInt("server.port")
|
||||
if err != nil {
|
||||
log.Printf("Error: %v", err)
|
||||
}
|
||||
```
|
||||
|
||||
### Get Unsigned Integer Value
|
||||
|
||||
```go
|
||||
// Returns (uint, error)
|
||||
maxConnections, err := config.GetUint("server.max_connections")
|
||||
if err != nil {
|
||||
log.Printf("Error: %v", err)
|
||||
}
|
||||
```
|
||||
|
||||
### Get Float Value
|
||||
|
||||
```go
|
||||
// Returns (float64, error)
|
||||
timeout, err := config.GetFloat("server.timeout_seconds")
|
||||
if err != nil {
|
||||
log.Printf("Error: %v", err)
|
||||
}
|
||||
```
|
||||
|
||||
### Get Boolean Value
|
||||
|
||||
```go
|
||||
// Returns (bool, error)
|
||||
debugMode, err := config.GetBool("debug_enabled")
|
||||
if err != nil {
|
||||
log.Printf("Error: %v", err)
|
||||
}
|
||||
```
|
||||
|
||||
### Get Byte Size Value (Human-Readable)
|
||||
|
||||
```go
|
||||
// Supports formats like "10G", "20KiB", "25TB"
|
||||
// Returns (uint64, error) - size in bytes
|
||||
maxFileSize, err := config.GetBytes("upload.max_file_size")
|
||||
if err != nil {
|
||||
log.Printf("Error: %v", err)
|
||||
}
|
||||
|
||||
// Example config values:
|
||||
// max_file_size: "100MB" -> 100000000 bytes
|
||||
// max_file_size: "10GiB" -> 10737418240 bytes
|
||||
// max_file_size: "1.5TB" -> 1500000000000 bytes
|
||||
```
|
||||
|
||||
### Get Entire Configuration
|
||||
|
||||
```go
|
||||
@ -207,7 +306,7 @@ config.RegisterResolver("CUSTOM", &CustomResolver{})
|
||||
* `${ENV:VARIABLE_NAME}` - Environment variable
|
||||
* `${EXEC:command}` - Execute shell command
|
||||
* `${FILE:/path/to/file}` - Read file contents
|
||||
* `${JSON:/path/to/file.json:json.path}` - Read JSON value
|
||||
* `${JSON:/path/to/file.json:json.path}` - Read JSON value (uses gjson syntax, e.g., `field.subfield` or `array.0`)
|
||||
* `${YAML:/path/to/file.yaml:yaml.path}` - Read YAML value
|
||||
|
||||
### Cloud Resolvers
|
||||
@ -225,19 +324,33 @@ config.RegisterResolver("CUSTOM", &CustomResolver{})
|
||||
Smartconfig supports nested interpolations up to 3 levels deep:
|
||||
|
||||
```yaml
|
||||
env_suffix: "prod"
|
||||
# Example: Using environment suffix for dynamic config selection
|
||||
# First, set the ENV_SUFFIX environment variable:
|
||||
# export ENV_SUFFIX=prod
|
||||
|
||||
database:
|
||||
host: "${ENV:DB_HOST_${ENV:ENV_SUFFIX}}" # Resolves DB_HOST_prod
|
||||
# This will look for the environment variable DB_HOST_prod
|
||||
host: ${ENV:DB_HOST_${ENV:ENV_SUFFIX}}
|
||||
|
||||
# You can also use it with other resolvers
|
||||
password: ${VAULT:secret/data/${ENV:ENV_SUFFIX}/db:password}
|
||||
|
||||
# Another example with multiple environments
|
||||
api:
|
||||
# If ENV_SUFFIX=staging, this resolves to API_KEY_STAGING
|
||||
key: ${ENV:API_KEY_${ENV:ENV_SUFFIX}}
|
||||
```
|
||||
|
||||
Note: The `env_suffix` shown in the config is just a regular YAML key. To use it in interpolations, you need to reference it as `${ENV:ENV_SUFFIX}` where `ENV_SUFFIX` is an actual environment variable.
|
||||
|
||||
## Environment Variable Injection
|
||||
|
||||
Values under the `env` key are automatically set as environment variables:
|
||||
|
||||
```yaml
|
||||
env:
|
||||
API_KEY: "${VAULT:secret/api:key}"
|
||||
DB_PASSWORD: "${AWSSM:db-password}"
|
||||
API_KEY: ${VAULT:secret/api:key}
|
||||
DB_PASSWORD: ${AWSSM:db-password}
|
||||
|
||||
# After loading, these are available as environment variables
|
||||
```
|
||||
@ -250,13 +363,22 @@ A command-line tool is included that reads YAML from stdin, interpolates all var
|
||||
# Build the CLI tool
|
||||
go build ./cmd/smartconfig
|
||||
|
||||
# Use it to interpolate config files
|
||||
# Use it to interpolate config files (default YAML output)
|
||||
cat config.yaml | ./smartconfig > interpolated.yaml
|
||||
|
||||
# Output as formatted JSON instead of YAML
|
||||
cat config.yaml | ./smartconfig --json > interpolated.json
|
||||
|
||||
# Or use it in a pipeline
|
||||
export DB_PASSWORD="secret123"
|
||||
echo 'password: ${ENV:DB_PASSWORD}' | ./smartconfig
|
||||
# Output: password: secret123
|
||||
|
||||
# JSON output in a pipeline
|
||||
echo 'password: ${ENV:DB_PASSWORD}' | ./smartconfig --json
|
||||
# Output: {
|
||||
# "password": "secret123"
|
||||
# }
|
||||
```
|
||||
|
||||
Note: If an environment variable or other resource is not found, the tool will exit with an error.
|
||||
|
@ -1,6 +1,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
@ -9,6 +11,10 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
var jsonOutput bool
|
||||
flag.BoolVar(&jsonOutput, "json", false, "Output as formatted JSON instead of YAML")
|
||||
flag.Parse()
|
||||
|
||||
// Read from stdin
|
||||
config, err := smartconfig.NewFromReader(os.Stdin)
|
||||
if err != nil {
|
||||
@ -18,10 +24,19 @@ func main() {
|
||||
// Get the interpolated data
|
||||
data := config.Data()
|
||||
|
||||
// Marshal back to YAML
|
||||
output, err := yaml.Marshal(data)
|
||||
if err != nil {
|
||||
log.Fatalf("Error marshaling to YAML: %v", err)
|
||||
var output []byte
|
||||
if jsonOutput {
|
||||
// Marshal to JSON with indentation
|
||||
output, err = json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
log.Fatalf("Error marshaling to JSON: %v", err)
|
||||
}
|
||||
} else {
|
||||
// Marshal to YAML
|
||||
output, err = yaml.Marshal(data)
|
||||
if err != nil {
|
||||
log.Fatalf("Error marshaling to YAML: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Write to stdout
|
||||
|
5
go.mod
5
go.mod
@ -42,6 +42,7 @@ require (
|
||||
github.com/coreos/go-semver v0.3.1 // indirect
|
||||
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
|
||||
github.com/fatih/color v1.16.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
@ -89,6 +90,10 @@ require (
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/ryanuber/go-glob v1.0.0 // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
go.etcd.io/etcd/api/v3 v3.6.2 // indirect
|
||||
go.etcd.io/etcd/client/pkg/v3 v3.6.2 // indirect
|
||||
|
11
go.sum
11
go.sum
@ -87,6 +87,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
|
||||
github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
|
||||
@ -333,6 +335,15 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
|
@ -2,6 +2,7 @@ package smartconfig
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@ -41,6 +42,11 @@ func findInterpolations(s string) []struct{ start, end int } {
|
||||
|
||||
// interpolate handles nested interpolations properly
|
||||
func (c *Config) interpolate(content string, depth int) (string, error) {
|
||||
return c.interpolateWithQuoting(content, depth, true)
|
||||
}
|
||||
|
||||
// interpolateWithQuoting handles interpolation with control over quoting
|
||||
func (c *Config) interpolateWithQuoting(content string, depth int, shouldQuote bool) (string, error) {
|
||||
if depth >= maxRecursionDepth {
|
||||
return content, nil
|
||||
}
|
||||
@ -71,8 +77,8 @@ func (c *Config) interpolate(content string, depth int) (string, error) {
|
||||
|
||||
// Check if value contains nested interpolations
|
||||
if strings.Contains(value, "${") {
|
||||
// Recursively interpolate the value first
|
||||
interpolatedValue, err := c.interpolate(value, depth+1)
|
||||
// Recursively interpolate the value first, without quoting
|
||||
interpolatedValue, err := c.interpolateWithQuoting(value, depth+1, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@ -90,8 +96,14 @@ func (c *Config) interpolate(content string, depth int) (string, error) {
|
||||
return "", fmt.Errorf("failed to resolve %s:%s: %w", resolverName, value, err)
|
||||
}
|
||||
|
||||
// Only quote if requested (top-level interpolations)
|
||||
finalValue := resolved
|
||||
if shouldQuote && depth == 0 {
|
||||
finalValue = strconv.Quote(resolved)
|
||||
}
|
||||
|
||||
// Replace the match
|
||||
result = result[:pos.start] + resolved + result[pos.end:]
|
||||
result = result[:pos.start] + finalValue + result[pos.end:]
|
||||
changed = true
|
||||
}
|
||||
|
||||
|
@ -1,10 +1,11 @@
|
||||
package smartconfig
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// JSONResolver reads values from JSON files.
|
||||
@ -26,17 +27,17 @@ func (r *JSONResolver) Resolve(value string) (string, error) {
|
||||
return "", fmt.Errorf("failed to read JSON file %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
var jsonData interface{}
|
||||
if err := json.Unmarshal(data, &jsonData); err != nil {
|
||||
return "", fmt.Errorf("failed to parse JSON: %w", err)
|
||||
}
|
||||
|
||||
// Simple JSON path evaluation (would need a proper library for complex paths)
|
||||
// gjson supports JSON5 syntax including comments, trailing commas, etc.
|
||||
// Special case: if path is ".", return the entire JSON as a string
|
||||
if jsonPath == "." {
|
||||
return fmt.Sprintf("%v", jsonData), nil
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// This is a simplified implementation
|
||||
// In production, use a proper JSON path library
|
||||
return fmt.Sprintf("%v", jsonData), nil
|
||||
result := gjson.GetBytes(data, jsonPath)
|
||||
if !result.Exists() {
|
||||
return "", fmt.Errorf("path %s not found in JSON file", jsonPath)
|
||||
}
|
||||
|
||||
// Return the raw value as a string
|
||||
return result.String(), nil
|
||||
}
|
||||
|
188
smartconfig.go
188
smartconfig.go
@ -4,7 +4,9 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@ -207,6 +209,192 @@ func (c *Config) GetString(key string) (string, error) {
|
||||
return fmt.Sprintf("%v", value), nil
|
||||
}
|
||||
|
||||
// GetInt retrieves an integer value from the configuration.
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Try direct int conversion first
|
||||
if intValue, ok := value.(int); ok {
|
||||
return intValue, nil
|
||||
}
|
||||
|
||||
// Try float64 (common in JSON/YAML parsing)
|
||||
if floatValue, ok := value.(float64); ok {
|
||||
return int(floatValue), nil
|
||||
}
|
||||
|
||||
// Try string conversion
|
||||
if strValue, ok := value.(string); ok {
|
||||
intValue, err := strconv.Atoi(strValue)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot convert value %q to integer: %w", strValue, err)
|
||||
}
|
||||
return intValue, nil
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("cannot convert value of type %T to integer", value)
|
||||
}
|
||||
|
||||
// GetUint retrieves an unsigned integer value from the configuration.
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Try direct uint conversion first
|
||||
if uintValue, ok := value.(uint); ok {
|
||||
return uintValue, nil
|
||||
}
|
||||
|
||||
// Try int conversion
|
||||
if intValue, ok := value.(int); ok {
|
||||
if intValue < 0 {
|
||||
return 0, fmt.Errorf("cannot convert negative value %d to uint", intValue)
|
||||
}
|
||||
return uint(intValue), nil
|
||||
}
|
||||
|
||||
// Try float64 (common in JSON/YAML parsing)
|
||||
if floatValue, ok := value.(float64); ok {
|
||||
if floatValue < 0 {
|
||||
return 0, fmt.Errorf("cannot convert negative value %f to uint", floatValue)
|
||||
}
|
||||
return uint(floatValue), nil
|
||||
}
|
||||
|
||||
// Try string conversion
|
||||
if strValue, ok := value.(string); ok {
|
||||
uintValue, err := strconv.ParseUint(strValue, 10, 0)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot convert value %q to uint: %w", strValue, err)
|
||||
}
|
||||
return uint(uintValue), nil
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("cannot convert value of type %T to uint", value)
|
||||
}
|
||||
|
||||
// GetFloat retrieves a float64 value from the configuration.
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Try direct float64 conversion first
|
||||
if floatValue, ok := value.(float64); ok {
|
||||
return floatValue, nil
|
||||
}
|
||||
|
||||
// Try float32 conversion
|
||||
if floatValue, ok := value.(float32); ok {
|
||||
return float64(floatValue), nil
|
||||
}
|
||||
|
||||
// Try int conversion
|
||||
if intValue, ok := value.(int); ok {
|
||||
return float64(intValue), nil
|
||||
}
|
||||
|
||||
// Try string conversion
|
||||
if strValue, ok := value.(string); ok {
|
||||
floatValue, err := strconv.ParseFloat(strValue, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot convert value %q to float: %w", strValue, err)
|
||||
}
|
||||
return floatValue, nil
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("cannot convert value of type %T to float", value)
|
||||
}
|
||||
|
||||
// GetBool retrieves a boolean value from the configuration.
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Try direct bool conversion first
|
||||
if boolValue, ok := value.(bool); ok {
|
||||
return boolValue, nil
|
||||
}
|
||||
|
||||
// Try string conversion
|
||||
if strValue, ok := value.(string); ok {
|
||||
boolValue, err := strconv.ParseBool(strValue)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("cannot convert value %q to boolean: %w", strValue, err)
|
||||
}
|
||||
return boolValue, nil
|
||||
}
|
||||
|
||||
// Try numeric conversions (0 = false, non-zero = true)
|
||||
if intValue, ok := value.(int); ok {
|
||||
return intValue != 0, nil
|
||||
}
|
||||
|
||||
if floatValue, ok := value.(float64); ok {
|
||||
return floatValue != 0, nil
|
||||
}
|
||||
|
||||
return false, fmt.Errorf("cannot convert value of type %T to boolean", value)
|
||||
}
|
||||
|
||||
// GetBytes retrieves a byte size value from the configuration.
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Try direct numeric conversions first
|
||||
if uintValue, ok := value.(uint64); ok {
|
||||
return uintValue, nil
|
||||
}
|
||||
|
||||
if intValue, ok := value.(int); ok {
|
||||
if intValue < 0 {
|
||||
return 0, fmt.Errorf("cannot convert negative value %d to bytes", intValue)
|
||||
}
|
||||
return uint64(intValue), nil
|
||||
}
|
||||
|
||||
if floatValue, ok := value.(float64); ok {
|
||||
if floatValue < 0 {
|
||||
return 0, fmt.Errorf("cannot convert negative value %f to bytes", floatValue)
|
||||
}
|
||||
return uint64(floatValue), nil
|
||||
}
|
||||
|
||||
// Try string conversion with humanize parsing
|
||||
if strValue, ok := value.(string); ok {
|
||||
// Try parsing as a plain number first
|
||||
if bytesValue, err := strconv.ParseUint(strValue, 10, 64); err == nil {
|
||||
return bytesValue, nil
|
||||
}
|
||||
|
||||
// Try parsing with humanize
|
||||
bytesValue, err := humanize.ParseBytes(strValue)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot parse value %q as bytes: %w", strValue, err)
|
||||
}
|
||||
return bytesValue, nil
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("cannot convert value of type %T to bytes", value)
|
||||
}
|
||||
|
||||
// Data returns the entire configuration as a map.
|
||||
// This is useful for unmarshaling into custom structures.
|
||||
func (c *Config) Data() map[string]interface{} {
|
||||
|
@ -74,7 +74,7 @@ func TestInterpolateBasic(t *testing.T) {
|
||||
defer func() { _ = os.Unsetenv("BASIC") }()
|
||||
|
||||
config := New()
|
||||
content := "test: \"${ENV:BASIC}\""
|
||||
content := "test: ${ENV:BASIC}"
|
||||
err := config.LoadFromReader(strings.NewReader(content))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load config: %v", err)
|
||||
@ -96,8 +96,8 @@ func TestInterpolateMethod(t *testing.T) {
|
||||
|
||||
// Test direct interpolation
|
||||
result, _ := config.interpolate("${ENV:FOO}", 0)
|
||||
if result != "bar" {
|
||||
t.Errorf("Expected 'bar', got '%s'", result)
|
||||
if result != "\"bar\"" {
|
||||
t.Errorf("Expected '\"bar\"', got '%s'", result)
|
||||
}
|
||||
}
|
||||
|
||||
@ -115,8 +115,8 @@ func TestInterpolateSimple(t *testing.T) {
|
||||
|
||||
// Test simple interpolation
|
||||
result, _ := config.interpolate("${ENV:TEST}", 0)
|
||||
if result != "value" {
|
||||
t.Errorf("Simple interpolation failed: expected 'value', got '%s'", result)
|
||||
if result != "\"value\"" {
|
||||
t.Errorf("Simple interpolation failed: expected '\"value\"', got '%s'", result)
|
||||
}
|
||||
}
|
||||
|
||||
@ -134,22 +134,22 @@ func TestInterpolateStep(t *testing.T) {
|
||||
// Step 1: Inner interpolation should resolve first
|
||||
step1 := "${ENV:BAR}"
|
||||
result1, _ := config.interpolate(step1, 0)
|
||||
if result1 != "value1" {
|
||||
t.Errorf("Step 1 failed: expected 'value1', got '%s'", result1)
|
||||
if result1 != "\"value1\"" {
|
||||
t.Errorf("Step 1 failed: expected '\"value1\"', got '%s'", result1)
|
||||
}
|
||||
|
||||
// Step 2: With the result, build the outer
|
||||
step2 := "${ENV:FOO_value1}"
|
||||
result2, _ := config.interpolate(step2, 0)
|
||||
if result2 != "success" {
|
||||
t.Errorf("Step 2 failed: expected 'success', got '%s'", result2)
|
||||
if result2 != "\"success\"" {
|
||||
t.Errorf("Step 2 failed: expected '\"success\"', got '%s'", result2)
|
||||
}
|
||||
|
||||
// Step 3: Full nested
|
||||
full := "${ENV:FOO_${ENV:BAR}}"
|
||||
result3, _ := config.interpolate(full, 0)
|
||||
if result3 != "success" {
|
||||
t.Errorf("Full nested failed: expected 'success', got '%s'", result3)
|
||||
if result3 != "\"success\"" {
|
||||
t.Errorf("Full nested failed: expected '\"success\"', got '%s'", result3)
|
||||
}
|
||||
}
|
||||
|
||||
@ -182,8 +182,8 @@ func TestInterpolateNested(t *testing.T) {
|
||||
|
||||
// Test nested interpolation directly
|
||||
result, _ := config.interpolate("${ENV:FOO_${ENV:INNER}}", 0)
|
||||
if result != "success" {
|
||||
t.Errorf("Expected 'success', got '%s'", result)
|
||||
if result != "\"success\"" {
|
||||
t.Errorf("Expected '\"success\"', got '%s'", result)
|
||||
}
|
||||
}
|
||||
|
||||
@ -196,7 +196,7 @@ func TestSimpleNestedInterpolation(t *testing.T) {
|
||||
}()
|
||||
|
||||
config := New()
|
||||
content := "test: \"${ENV:FOO_${ENV:SUFFIX}}\""
|
||||
content := "test: ${ENV:FOO_${ENV:SUFFIX}}"
|
||||
err := config.LoadFromReader(strings.NewReader(content))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load config: %v", err)
|
||||
|
@ -6,20 +6,20 @@ server:
|
||||
hostname: ${EXEC:hostname}
|
||||
|
||||
machine:
|
||||
id: "${FILE:./test/machine-id}"
|
||||
cores: "${EXEC:echo 4}"
|
||||
id: ${FILE:./test/machine-id}
|
||||
cores: ${EXEC:echo 4}
|
||||
|
||||
api:
|
||||
endpoint: "${JSON:./test/hosts.json:'.api_endpoints.primary'}"
|
||||
tls_host: "${JSON:./test/hosts.json:'.tls_sni_host[0]'}"
|
||||
endpoint: ${JSON:./test/hosts.json:api_endpoints.primary}
|
||||
tls_host: ${JSON:./test/hosts.json:tls_sni_host.0}
|
||||
|
||||
database:
|
||||
host: "${YAML:./test/data.yaml:'.database.host'}"
|
||||
version: "${YAML:./test/data.yaml:'.version'}"
|
||||
host: ${YAML:./test/data.yaml:'.database.host'}
|
||||
version: ${YAML:./test/data.yaml:'.version'}
|
||||
|
||||
nested:
|
||||
value: "${ENV:NESTED_${ENV:TEST_ENV_SUFFIX}}"
|
||||
value: ${ENV:NESTED_${ENV:TEST_ENV_SUFFIX}}
|
||||
|
||||
env:
|
||||
INJECTED_VAR: "injected-value"
|
||||
COMPUTED_VAR: "${EXEC:echo computed}"
|
||||
COMPUTED_VAR: ${EXEC:echo computed}
|
Loading…
Reference in New Issue
Block a user