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
|
||||||
|
|
||||||
smartconfig is a go library that reads a configuration file from the
|
smartconfig is a go library that reads YAML configuration files from the
|
||||||
filesystem and interpolates variables in it from several sources, then
|
filesystem and interpolates variables in them from several sources, then
|
||||||
parses the config file as yaml.
|
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.
|
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.
|
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
|
config file to serve as a bridge between fancy backend secret management
|
||||||
services and "traditional" configuration via env vars.
|
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
|
# Usage
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# config.yaml
|
# config.yaml
|
||||||
|
|
||||||
name: ${ENV:APPLICATION_NAME}
|
name: ${ENV:APPLICATION_NAME}
|
||||||
host: ${EXEC:"hostname -s"}
|
host: ${EXEC:hostname -s}
|
||||||
port: ${ENV:PORT}
|
port: ${ENV:PORT}
|
||||||
|
|
||||||
vhost:
|
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:
|
machine:
|
||||||
id: "${FILE:/etc/machine-id}"
|
id: ${FILE:/etc/machine-id}
|
||||||
temperature: "${FILE:/sys/class/thermal/thermal_zone0/temp}"
|
temperature: ${FILE:/sys/class/thermal/thermal_zone0/temp}
|
||||||
num_cpus: "${EXEC:nproc}"
|
num_cpus: ${EXEC:nproc}
|
||||||
|
|
||||||
api:
|
api:
|
||||||
root_user: "admin"
|
root_user: "admin"
|
||||||
root_password: "${AWSSM:root_password}"
|
root_password: ${AWSSM:root_password}
|
||||||
|
|
||||||
db:
|
db:
|
||||||
host: "${GCPSM:${ENV:APPLICATION_NAME}_DB_HOST}"
|
host: ${GCPSM:${ENV:APPLICATION_NAME}_DB_HOST}
|
||||||
user: "${GCPSM:${ENV:APPLICATION_NAME}_DB_USER}"
|
user: ${GCPSM:${ENV:APPLICATION_NAME}_DB_USER}
|
||||||
password: "${GCPSM:${ENV:APPLICATION_NAME}_DB_PASSWORD}"
|
password: ${GCPSM:${ENV:APPLICATION_NAME}_DB_PASSWORD}
|
||||||
|
|
||||||
external:
|
external:
|
||||||
google_api_key: "${CONSUL:secret:google_api_key}"
|
google_api_key: ${CONSUL:secret:google_api_key}
|
||||||
twilio_api_key: "${VAULT:secret:twilio_api_key}"
|
twilio_api_key: ${VAULT:secret:twilio_api_key}
|
||||||
|
|
||||||
env:
|
env:
|
||||||
ENCRYPTION_PUBLIC_KEY: "${EXEC:secret get myapp/encryption_public_key}"
|
ENCRYPTION_PUBLIC_KEY: ${EXEC:secret get myapp/encryption_public_key}
|
||||||
SLACK_WEBHOOK_URL: "${FILE:/etc/config/slack_webhook_url.txt}"
|
SLACK_WEBHOOK_URL: ${FILE:/etc/config/slack_webhook_url.txt}
|
||||||
```
|
```
|
||||||
|
|
||||||
# Supported Providers
|
# Supported Providers
|
||||||
@ -74,7 +117,7 @@ env:
|
|||||||
* AZURESM - Azure Key Vault
|
* AZURESM - Azure Key Vault
|
||||||
* K8SS - Kubernetes Secrets
|
* K8SS - Kubernetes Secrets
|
||||||
* FILE - read from a file
|
* 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
|
* YAML - read from a YAML file
|
||||||
* ETCD - etcd key-value store
|
* 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
|
### Get Entire Configuration
|
||||||
|
|
||||||
```go
|
```go
|
||||||
@ -207,7 +306,7 @@ config.RegisterResolver("CUSTOM", &CustomResolver{})
|
|||||||
* `${ENV:VARIABLE_NAME}` - Environment variable
|
* `${ENV:VARIABLE_NAME}` - Environment variable
|
||||||
* `${EXEC:command}` - Execute shell command
|
* `${EXEC:command}` - Execute shell command
|
||||||
* `${FILE:/path/to/file}` - Read file contents
|
* `${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
|
* `${YAML:/path/to/file.yaml:yaml.path}` - Read YAML value
|
||||||
|
|
||||||
### Cloud Resolvers
|
### Cloud Resolvers
|
||||||
@ -225,19 +324,33 @@ config.RegisterResolver("CUSTOM", &CustomResolver{})
|
|||||||
Smartconfig supports nested interpolations up to 3 levels deep:
|
Smartconfig supports nested interpolations up to 3 levels deep:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
env_suffix: "prod"
|
# Example: Using environment suffix for dynamic config selection
|
||||||
|
# First, set the ENV_SUFFIX environment variable:
|
||||||
|
# export ENV_SUFFIX=prod
|
||||||
|
|
||||||
database:
|
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
|
## Environment Variable Injection
|
||||||
|
|
||||||
Values under the `env` key are automatically set as environment variables:
|
Values under the `env` key are automatically set as environment variables:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
env:
|
env:
|
||||||
API_KEY: "${VAULT:secret/api:key}"
|
API_KEY: ${VAULT:secret/api:key}
|
||||||
DB_PASSWORD: "${AWSSM:db-password}"
|
DB_PASSWORD: ${AWSSM:db-password}
|
||||||
|
|
||||||
# After loading, these are available as environment variables
|
# 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
|
# Build the CLI tool
|
||||||
go build ./cmd/smartconfig
|
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
|
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
|
# Or use it in a pipeline
|
||||||
export DB_PASSWORD="secret123"
|
export DB_PASSWORD="secret123"
|
||||||
echo 'password: ${ENV:DB_PASSWORD}' | ./smartconfig
|
echo 'password: ${ENV:DB_PASSWORD}' | ./smartconfig
|
||||||
# Output: password: secret123
|
# 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.
|
Note: If an environment variable or other resource is not found, the tool will exit with an error.
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
@ -9,6 +11,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
var jsonOutput bool
|
||||||
|
flag.BoolVar(&jsonOutput, "json", false, "Output as formatted JSON instead of YAML")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
// Read from stdin
|
// Read from stdin
|
||||||
config, err := smartconfig.NewFromReader(os.Stdin)
|
config, err := smartconfig.NewFromReader(os.Stdin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -18,10 +24,19 @@ func main() {
|
|||||||
// Get the interpolated data
|
// Get the interpolated data
|
||||||
data := config.Data()
|
data := config.Data()
|
||||||
|
|
||||||
// Marshal back to YAML
|
var output []byte
|
||||||
output, err := yaml.Marshal(data)
|
if jsonOutput {
|
||||||
if err != nil {
|
// Marshal to JSON with indentation
|
||||||
log.Fatalf("Error marshaling to YAML: %v", err)
|
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
|
// 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-semver v0.3.1 // indirect
|
||||||
github.com/coreos/go-systemd/v22 v22.5.0 // 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/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/emicklei/go-restful/v3 v3.11.0 // indirect
|
||||||
github.com/fatih/color v1.16.0 // indirect
|
github.com/fatih/color v1.16.0 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // 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/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/ryanuber/go-glob v1.0.0 // 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
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
go.etcd.io/etcd/api/v3 v3.6.2 // indirect
|
go.etcd.io/etcd/api/v3 v3.6.2 // indirect
|
||||||
go.etcd.io/etcd/client/pkg/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/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 h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
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 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
|
||||||
github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
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=
|
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.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
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/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/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 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
|
@ -2,6 +2,7 @@ package smartconfig
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -41,6 +42,11 @@ func findInterpolations(s string) []struct{ start, end int } {
|
|||||||
|
|
||||||
// interpolate handles nested interpolations properly
|
// interpolate handles nested interpolations properly
|
||||||
func (c *Config) interpolate(content string, depth int) (string, error) {
|
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 {
|
if depth >= maxRecursionDepth {
|
||||||
return content, nil
|
return content, nil
|
||||||
}
|
}
|
||||||
@ -71,8 +77,8 @@ func (c *Config) interpolate(content string, depth int) (string, error) {
|
|||||||
|
|
||||||
// Check if value contains nested interpolations
|
// Check if value contains nested interpolations
|
||||||
if strings.Contains(value, "${") {
|
if strings.Contains(value, "${") {
|
||||||
// Recursively interpolate the value first
|
// Recursively interpolate the value first, without quoting
|
||||||
interpolatedValue, err := c.interpolate(value, depth+1)
|
interpolatedValue, err := c.interpolateWithQuoting(value, depth+1, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
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)
|
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
|
// Replace the match
|
||||||
result = result[:pos.start] + resolved + result[pos.end:]
|
result = result[:pos.start] + finalValue + result[pos.end:]
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
package smartconfig
|
package smartconfig
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
)
|
)
|
||||||
|
|
||||||
// JSONResolver reads values from JSON files.
|
// 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)
|
return "", fmt.Errorf("failed to read JSON file %s: %w", filePath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var jsonData interface{}
|
// gjson supports JSON5 syntax including comments, trailing commas, etc.
|
||||||
if err := json.Unmarshal(data, &jsonData); err != nil {
|
// Special case: if path is ".", return the entire JSON as a string
|
||||||
return "", fmt.Errorf("failed to parse JSON: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simple JSON path evaluation (would need a proper library for complex paths)
|
|
||||||
if jsonPath == "." {
|
if jsonPath == "." {
|
||||||
return fmt.Sprintf("%v", jsonData), nil
|
return string(data), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// This is a simplified implementation
|
result := gjson.GetBytes(data, jsonPath)
|
||||||
// In production, use a proper JSON path library
|
if !result.Exists() {
|
||||||
return fmt.Sprintf("%v", jsonData), nil
|
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"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/dustin/go-humanize"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -207,6 +209,192 @@ func (c *Config) GetString(key string) (string, error) {
|
|||||||
return fmt.Sprintf("%v", value), nil
|
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.
|
// Data returns the entire configuration as a map.
|
||||||
// This is useful for unmarshaling into custom structures.
|
// This is useful for unmarshaling into custom structures.
|
||||||
func (c *Config) Data() map[string]interface{} {
|
func (c *Config) Data() map[string]interface{} {
|
||||||
|
@ -74,7 +74,7 @@ func TestInterpolateBasic(t *testing.T) {
|
|||||||
defer func() { _ = os.Unsetenv("BASIC") }()
|
defer func() { _ = os.Unsetenv("BASIC") }()
|
||||||
|
|
||||||
config := New()
|
config := New()
|
||||||
content := "test: \"${ENV:BASIC}\""
|
content := "test: ${ENV:BASIC}"
|
||||||
err := config.LoadFromReader(strings.NewReader(content))
|
err := config.LoadFromReader(strings.NewReader(content))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to load config: %v", err)
|
t.Fatalf("Failed to load config: %v", err)
|
||||||
@ -96,8 +96,8 @@ func TestInterpolateMethod(t *testing.T) {
|
|||||||
|
|
||||||
// Test direct interpolation
|
// Test direct interpolation
|
||||||
result, _ := config.interpolate("${ENV:FOO}", 0)
|
result, _ := config.interpolate("${ENV:FOO}", 0)
|
||||||
if result != "bar" {
|
if result != "\"bar\"" {
|
||||||
t.Errorf("Expected 'bar', got '%s'", result)
|
t.Errorf("Expected '\"bar\"', got '%s'", result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -115,8 +115,8 @@ func TestInterpolateSimple(t *testing.T) {
|
|||||||
|
|
||||||
// Test simple interpolation
|
// Test simple interpolation
|
||||||
result, _ := config.interpolate("${ENV:TEST}", 0)
|
result, _ := config.interpolate("${ENV:TEST}", 0)
|
||||||
if result != "value" {
|
if result != "\"value\"" {
|
||||||
t.Errorf("Simple interpolation failed: expected 'value', got '%s'", result)
|
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
|
// Step 1: Inner interpolation should resolve first
|
||||||
step1 := "${ENV:BAR}"
|
step1 := "${ENV:BAR}"
|
||||||
result1, _ := config.interpolate(step1, 0)
|
result1, _ := config.interpolate(step1, 0)
|
||||||
if result1 != "value1" {
|
if result1 != "\"value1\"" {
|
||||||
t.Errorf("Step 1 failed: expected 'value1', got '%s'", result1)
|
t.Errorf("Step 1 failed: expected '\"value1\"', got '%s'", result1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: With the result, build the outer
|
// Step 2: With the result, build the outer
|
||||||
step2 := "${ENV:FOO_value1}"
|
step2 := "${ENV:FOO_value1}"
|
||||||
result2, _ := config.interpolate(step2, 0)
|
result2, _ := config.interpolate(step2, 0)
|
||||||
if result2 != "success" {
|
if result2 != "\"success\"" {
|
||||||
t.Errorf("Step 2 failed: expected 'success', got '%s'", result2)
|
t.Errorf("Step 2 failed: expected '\"success\"', got '%s'", result2)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Full nested
|
// Step 3: Full nested
|
||||||
full := "${ENV:FOO_${ENV:BAR}}"
|
full := "${ENV:FOO_${ENV:BAR}}"
|
||||||
result3, _ := config.interpolate(full, 0)
|
result3, _ := config.interpolate(full, 0)
|
||||||
if result3 != "success" {
|
if result3 != "\"success\"" {
|
||||||
t.Errorf("Full nested failed: expected 'success', got '%s'", result3)
|
t.Errorf("Full nested failed: expected '\"success\"', got '%s'", result3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -182,8 +182,8 @@ func TestInterpolateNested(t *testing.T) {
|
|||||||
|
|
||||||
// Test nested interpolation directly
|
// Test nested interpolation directly
|
||||||
result, _ := config.interpolate("${ENV:FOO_${ENV:INNER}}", 0)
|
result, _ := config.interpolate("${ENV:FOO_${ENV:INNER}}", 0)
|
||||||
if result != "success" {
|
if result != "\"success\"" {
|
||||||
t.Errorf("Expected 'success', got '%s'", result)
|
t.Errorf("Expected '\"success\"', got '%s'", result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -196,7 +196,7 @@ func TestSimpleNestedInterpolation(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
config := New()
|
config := New()
|
||||||
content := "test: \"${ENV:FOO_${ENV:SUFFIX}}\""
|
content := "test: ${ENV:FOO_${ENV:SUFFIX}}"
|
||||||
err := config.LoadFromReader(strings.NewReader(content))
|
err := config.LoadFromReader(strings.NewReader(content))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to load config: %v", err)
|
t.Fatalf("Failed to load config: %v", err)
|
||||||
|
@ -6,20 +6,20 @@ server:
|
|||||||
hostname: ${EXEC:hostname}
|
hostname: ${EXEC:hostname}
|
||||||
|
|
||||||
machine:
|
machine:
|
||||||
id: "${FILE:./test/machine-id}"
|
id: ${FILE:./test/machine-id}
|
||||||
cores: "${EXEC:echo 4}"
|
cores: ${EXEC:echo 4}
|
||||||
|
|
||||||
api:
|
api:
|
||||||
endpoint: "${JSON:./test/hosts.json:'.api_endpoints.primary'}"
|
endpoint: ${JSON:./test/hosts.json:api_endpoints.primary}
|
||||||
tls_host: "${JSON:./test/hosts.json:'.tls_sni_host[0]'}"
|
tls_host: ${JSON:./test/hosts.json:tls_sni_host.0}
|
||||||
|
|
||||||
database:
|
database:
|
||||||
host: "${YAML:./test/data.yaml:'.database.host'}"
|
host: ${YAML:./test/data.yaml:'.database.host'}
|
||||||
version: "${YAML:./test/data.yaml:'.version'}"
|
version: ${YAML:./test/data.yaml:'.version'}
|
||||||
|
|
||||||
nested:
|
nested:
|
||||||
value: "${ENV:NESTED_${ENV:TEST_ENV_SUFFIX}}"
|
value: ${ENV:NESTED_${ENV:TEST_ENV_SUFFIX}}
|
||||||
|
|
||||||
env:
|
env:
|
||||||
INJECTED_VAR: "injected-value"
|
INJECTED_VAR: "injected-value"
|
||||||
COMPUTED_VAR: "${EXEC:echo computed}"
|
COMPUTED_VAR: ${EXEC:echo computed}
|
Loading…
Reference in New Issue
Block a user