Skip to content

Commit 3b6fa3f

Browse files
committed
add toml support
1 parent 6147c0e commit 3b6fa3f

File tree

7 files changed

+257
-100
lines changed

7 files changed

+257
-100
lines changed

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
# Resolver Package
22

3-
The `resolver` package provides a flexible and extensible way to resolve configuration values from various sources including environment variables, files, JSON, YAML, INI, and key-value files. It uses a prefix-based system to identify which resolver to use and returns the resolved value or an error if something goes wrong.
3+
The `resolver` package provides a flexible and extensible way to resolve configuration values from various sources including environment variables, files, `JSON`, `YAML`, `INI`, `TOML` and key-value files. It uses a prefix-based system to identify which resolver to use and returns the resolved value or an error if something goes wrong.
44

55
## Installation
66

77
```bash
8-
go get github.com/yourusername/yourrepo/resolver
8+
go get github.com/containeroo/resolver/resolver
99
```
1010

1111
## Usage
@@ -22,6 +22,8 @@ The primary entry point is the `ResolveVariable` function. It takes a single str
2222
Example: `yaml:/config/app.yaml//server.port` returns `port` under `server` in `app.yaml`.It is also possible to indexing into arrays (e.g., `yaml:/config/app.yaml//servers.0.host`).
2323
- `ini`: – Resolves values from an INI file. Can specify a section and key, or just a key in the default section.
2424
Example: `ini:/config/app.ini//Section.Key` returns the value of `Key` under `Section`.
25+
- `toml`: – Resolves values from a TOML file.
26+
Example: `toml:/config/app.toml//server.host` returns `host` under `server` in `app.toml`.
2527
- No prefix – Returns the value as-is, unchanged.
2628

2729
## Example
@@ -34,7 +36,7 @@ import (
3436
"log"
3537
"os"
3638

37-
"github.com/containeroo/portpatrol/resolver"
39+
"github.com/containeroo/resolver"
3840
)
3941

4042
func main() {

coverage.out

Lines changed: 0 additions & 92 deletions
This file was deleted.

go.mod

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
module github.com/containeroo/resolver
2+
3+
go 1.24.2
4+
5+
require (
6+
github.com/pelletier/go-toml/v2 v2.2.4
7+
github.com/stretchr/testify v1.10.0
8+
gopkg.in/ini.v1 v1.67.0
9+
gopkg.in/yaml.v3 v3.0.1
10+
)
11+
12+
require (
13+
github.com/davecgh/go-spew v1.1.1 // indirect
14+
github.com/pmezard/go-difflib v1.0.0 // indirect
15+
)

go.sum

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
2+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3+
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
4+
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
5+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
6+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
7+
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
8+
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
9+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
10+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
11+
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
12+
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
13+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
14+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

resolver.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,12 @@ type Resolver interface {
1212

1313
// Prefixes for different resolvers
1414
const (
15-
envPrefix = "env:"
16-
jsonPrefix = "json:"
17-
yamlPrefix = "yaml:"
18-
iniPrefix = "ini:"
19-
filePrefix = "file:"
15+
envPrefix string = "env:"
16+
filePrefix string = "file:"
17+
iniPrefix string = "ini:"
18+
jsonPrefix string = "json:"
19+
tomlPrefix string = "toml:"
20+
yamlPrefix string = "yaml:"
2021
)
2122

2223
// Global registry of resolvers
@@ -26,6 +27,7 @@ var resolvers = map[string]Resolver{
2627
yamlPrefix: &YAMLResolver{},
2728
iniPrefix: &INIResolver{},
2829
filePrefix: &INIResolver{},
30+
tomlPrefix: &TOMLResolver{},
2931
}
3032

3133
// ResolveVariable attempts to resolve the given value by checking for known prefixes.

toml.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package resolver
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"strings"
7+
8+
"github.com/pelletier/go-toml/v2"
9+
)
10+
11+
// Resolves a value by loading a TOML file and extracting a nested key.
12+
// The value after the prefix should be in the format "path/to/file.toml//key1.key2.keyN"
13+
// If no key is provided, returns the entire TOML file as a string.
14+
// Example:
15+
// "toml:/config/app.toml//server.host"
16+
// would load app.toml, parse it as TOML, and then return the value at server.host.
17+
//
18+
// Keys are navigated via dot notation.
19+
// If no key is provided (no "//" present), returns the entire TOML file as string.
20+
type TOMLResolver struct{}
21+
22+
func (r *TOMLResolver) Resolve(value string) (string, error) {
23+
filePath, keyPath := splitFileAndKey(value)
24+
filePath = os.ExpandEnv(filePath)
25+
26+
data, err := os.ReadFile(filePath)
27+
if err != nil {
28+
return "", fmt.Errorf("failed to read TOML file '%s': %w", filePath, err)
29+
}
30+
31+
// Validate TOML syntax by decoding into a dummy struct
32+
var validationTarget struct{}
33+
if err := toml.Unmarshal(data, &validationTarget); err != nil {
34+
return "", fmt.Errorf("failed to parse TOML in '%s': %w", filePath, err)
35+
}
36+
37+
// Decode into navigable structure
38+
var content map[string]any
39+
if err := toml.Unmarshal(data, &content); err != nil {
40+
return "", fmt.Errorf("failed to parse TOML in '%s': %w", filePath, err)
41+
}
42+
43+
if keyPath == "" {
44+
return strings.TrimSpace(string(data)), nil
45+
}
46+
47+
val, err := navigateData(content, strings.Split(keyPath, "."))
48+
if err != nil {
49+
return "", fmt.Errorf("key path '%s' not found in TOML '%s': %w", keyPath, filePath, err)
50+
}
51+
52+
if strVal, ok := val.(string); ok {
53+
return strVal, nil
54+
}
55+
56+
tomlVal, err := toml.Marshal(val)
57+
if err != nil {
58+
return "", fmt.Errorf("failed to encode TOML value: %w", err)
59+
}
60+
61+
return strings.TrimSpace(string(tomlVal)), nil
62+
}

toml_test.go

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
package resolver
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
)
10+
11+
func createTomlTestFile(t *testing.T) string {
12+
t.Helper()
13+
14+
tempDir := t.TempDir()
15+
testFilePath := filepath.Join(tempDir, "config.toml")
16+
17+
fileContent := `emptyString = ""
18+
19+
[server]
20+
host = "localhost"
21+
port = 8080
22+
[server.nested]
23+
key = "value"
24+
25+
[[servers]]
26+
host = "example.com"
27+
port = 80
28+
29+
[[servers]]
30+
host = "example.org"
31+
port = 443
32+
33+
[nonString]
34+
inner = true
35+
`
36+
37+
err := os.WriteFile(testFilePath, []byte(fileContent), 0666)
38+
assert.NoError(t, err, "failed to create test TOML file")
39+
40+
return testFilePath
41+
}
42+
43+
func TestTOMLResolver_Resolve(t *testing.T) {
44+
t.Parallel()
45+
resolver := &TOMLResolver{}
46+
47+
t.Run("Resolve entire file", func(t *testing.T) {
48+
t.Parallel()
49+
50+
testFilePath := createTomlTestFile(t)
51+
val, err := resolver.Resolve(testFilePath)
52+
assert.NoError(t, err, "unexpected error resolving entire TOML file")
53+
54+
expected := `emptyString = ""
55+
56+
[server]
57+
host = "localhost"
58+
port = 8080
59+
[server.nested]
60+
key = "value"
61+
62+
[[servers]]
63+
host = "example.com"
64+
port = 80
65+
66+
[[servers]]
67+
host = "example.org"
68+
port = 443
69+
70+
[nonString]
71+
inner = true`
72+
assert.Equal(t, expected, val)
73+
})
74+
75+
t.Run("Resolve top-level key", func(t *testing.T) {
76+
t.Parallel()
77+
78+
testFilePath := createTomlTestFile(t)
79+
val, err := resolver.Resolve(testFilePath + "//server.host")
80+
assert.NoError(t, err, "unexpected error resolving top-level key")
81+
assert.Equal(t, "localhost", val)
82+
})
83+
84+
t.Run("Resolve nested key", func(t *testing.T) {
85+
t.Parallel()
86+
87+
testFilePath := createTomlTestFile(t)
88+
val, err := resolver.Resolve(testFilePath + "//server.nested.key")
89+
assert.NoError(t, err, "unexpected error resolving nested key")
90+
assert.Equal(t, "value", val)
91+
})
92+
93+
t.Run("Resolve array element", func(t *testing.T) {
94+
t.Parallel()
95+
96+
testFilePath := createTomlTestFile(t)
97+
val, err := resolver.Resolve(testFilePath + "//servers.1.host")
98+
assert.NoError(t, err, "unexpected error resolving array element")
99+
assert.Equal(t, "example.org", val)
100+
})
101+
102+
t.Run("Resolve empty string key", func(t *testing.T) {
103+
t.Parallel()
104+
105+
testFilePath := createTomlTestFile(t)
106+
val, err := resolver.Resolve(testFilePath + "//emptyString")
107+
assert.NoError(t, err, "unexpected error resolving empty string key")
108+
assert.Equal(t, "", val)
109+
})
110+
111+
t.Run("Resolve non-string value", func(t *testing.T) {
112+
t.Parallel()
113+
114+
testFilePath := createTomlTestFile(t)
115+
val, err := resolver.Resolve(testFilePath + "//nonString")
116+
assert.NoError(t, err, "unexpected error resolving non-string value")
117+
118+
expected := `inner = true`
119+
assert.Equal(t, expected, val)
120+
})
121+
122+
t.Run("Resolve missing key", func(t *testing.T) {
123+
t.Parallel()
124+
125+
testFilePath := createTomlTestFile(t)
126+
_, err := resolver.Resolve(testFilePath + "//server.missing")
127+
assert.Error(t, err, "expected an error resolving a missing key, but got none")
128+
})
129+
130+
t.Run("Resolve non-existing file", func(t *testing.T) {
131+
t.Parallel()
132+
133+
tempDir := t.TempDir()
134+
nonExistentFile := filepath.Join(tempDir, "nonexistent.toml")
135+
136+
_, err := resolver.Resolve(nonExistentFile)
137+
assert.Error(t, err, "expected an error resolving a non-existing file, but got none")
138+
})
139+
140+
t.Run("Invalid TOML", func(t *testing.T) {
141+
t.Parallel()
142+
143+
tempDir := t.TempDir()
144+
testFilePath := filepath.Join(tempDir, "bad.toml")
145+
146+
invalid := "= invalid"
147+
err := os.WriteFile(testFilePath, []byte(invalid), 0666)
148+
assert.NoError(t, err)
149+
150+
result, err := resolver.Resolve(testFilePath)
151+
assert.Equal(t, "", result)
152+
assert.Error(t, err, "expected parse error but got none")
153+
})
154+
}

0 commit comments

Comments
 (0)