Skip to content

Commit ca943da

Browse files
authored
feat(sdk): add contrib/viper remote config provider
- Go applications using Viper for configuration had no way to read OpenDecree values without custom glue code. - Added sdk/contrib/viper — implements Viper's remote config provider interface backed by configclient.GetAll. - Converts flat field paths into nested JSON structures that Viper's key lookup traverses naturally. Closes #14
1 parent 1bab8ee commit ca943da

7 files changed

Lines changed: 564 additions & 2 deletions

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,7 @@ jobs:
257257
cd sdk/configwatcher && go test ./... -count=1 -coverprofile=../../cov-configwatcher.out -covermode=atomic & pids+=($!)
258258
cd sdk/grpctransport && go test ./... -count=1 -coverprofile=../../cov-grpctransport.out -covermode=atomic & pids+=($!)
259259
cd sdk/tools && go test ./... -count=1 -coverprofile=../../cov-tools.out -covermode=atomic & pids+=($!)
260+
cd sdk/contrib/viper && go test ./... -count=1 -coverprofile=../../cov-contrib-viper.out -covermode=atomic & pids+=($!)
260261
cd cmd/decree && go test ./... -count=1 -coverprofile=../../cov-decree.out -covermode=atomic & pids+=($!)
261262
fail=0
262263
for pid in "${pids[@]}"; do wait "$pid" || fail=1; done
@@ -276,7 +277,7 @@ jobs:
276277
- name: Merge coverage profiles
277278
run: |
278279
echo "mode: atomic" > coverage.out
279-
for f in coverage-internal.out cov-configclient.out cov-adminclient.out cov-configwatcher.out cov-grpctransport.out cov-tools.out cov-decree.out; do
280+
for f in coverage-internal.out cov-configclient.out cov-adminclient.out cov-configwatcher.out cov-grpctransport.out cov-tools.out cov-contrib-viper.out cov-decree.out; do
280281
[ -f "$f" ] && grep -v "^mode:" "$f" >> coverage.out || true
281282
done
282283

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ SERVER_LDFLAGS := -X github.com/opendecree/decree/internal/version.Version=$(GIT
2222
CLI_LDFLAGS := -X main.cliVersion=$(GIT_VERSION) -X main.cliCommit=$(GIT_COMMIT)
2323

2424
# Module list for multi-module operations.
25-
SDK_MODULES := sdk/retry sdk/configclient sdk/adminclient sdk/configwatcher sdk/grpctransport sdk/tools
25+
SDK_MODULES := sdk/retry sdk/configclient sdk/adminclient sdk/configwatcher sdk/grpctransport sdk/tools sdk/contrib/viper
2626

2727
.PHONY: all generate generate-proto generate-sqlc deps test lint lint-go lint-proto lint-migrations build image ui migrate e2e e2e-jwt examples bench bench-e2e stress chaos docs docs-api docs-cli docs-man docs-serve docs-deploy pre-commit clean tools help demo-gif validate-meta-schemas
2828

sdk/contrib/viper/README.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# decree contrib/viper
2+
3+
A [Viper](https://github.com/spf13/viper) remote config provider backed by an OpenDecree `configclient`. It allows Go applications that use Viper for configuration to transparently read values from OpenDecree without changing their existing Viper-based code.
4+
5+
> **Alpha**: This module is part of the OpenDecree alpha release. The API may change.
6+
7+
## Installation
8+
9+
```sh
10+
go get github.com/opendecree/decree/sdk/contrib/viper
11+
```
12+
13+
## Usage
14+
15+
```go
16+
import (
17+
"github.com/opendecree/decree/sdk/configclient"
18+
"github.com/opendecree/decree/sdk/grpctransport"
19+
vipercontrib "github.com/opendecree/decree/sdk/contrib/viper"
20+
"github.com/spf13/viper"
21+
)
22+
23+
func main() {
24+
// Create a configclient backed by the gRPC transport.
25+
conn, _ := grpc.Dial("localhost:9090", grpc.WithTransportCredentials(insecure.NewCredentials()))
26+
transport := grpctransport.NewConfigTransport(conn)
27+
client := configclient.New(transport)
28+
29+
// Create and register the provider for your tenant.
30+
p := vipercontrib.New(client, "my-tenant")
31+
vipercontrib.Register("decree", p)
32+
33+
// Configure Viper to use the provider.
34+
viper.SetConfigType("json")
35+
viper.AddRemoteProvider("decree", "decree://local", "")
36+
if err := viper.ReadRemoteConfig(); err != nil {
37+
log.Fatal(err)
38+
}
39+
40+
// Use Viper as normal.
41+
fmt.Println(viper.GetString("app.name"))
42+
fmt.Println(viper.GetBool("feature.flag"))
43+
}
44+
```
45+
46+
Decree field paths use dot notation (e.g. `"app.name"`, `"feature.flag"`). These are mapped to Viper's hierarchical key model, so `viper.GetString("app.name")` works as expected.
47+
48+
## Options
49+
50+
```go
51+
// WithTimeout sets the per-fetch request timeout (default: 5s).
52+
p := vipercontrib.New(client, "my-tenant", vipercontrib.WithTimeout(10*time.Second))
53+
```
54+
55+
## Limitations
56+
57+
- **Read-only**: this provider does not support writing config values back to OpenDecree. Use `configclient.Client` directly for writes.
58+
- **No real-time watch**: `WatchRemoteConfig` and `WatchRemoteConfigOnChannel` use polling (30-second interval) rather than a live subscription. For real-time updates use `configwatcher`.
59+
- **All values are strings**: `GetAll` returns string representations of all field types. Use `configclient` typed getters (`GetInt`, `GetBool`, etc.) when type fidelity matters.
60+
- **Single tenant per provider**: each `Provider` is scoped to one tenant. Register multiple providers under different names if you need to read from multiple tenants.

sdk/contrib/viper/go.mod

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
module github.com/opendecree/decree/sdk/contrib/viper
2+
3+
go 1.22.0
4+
5+
require (
6+
github.com/opendecree/decree/sdk/configclient v0.1.2
7+
github.com/spf13/viper v1.19.0
8+
)
9+
10+
require (
11+
github.com/fsnotify/fsnotify v1.7.0 // indirect
12+
github.com/hashicorp/hcl v1.0.0 // indirect
13+
github.com/magiconair/properties v1.8.7 // indirect
14+
github.com/mitchellh/mapstructure v1.5.0 // indirect
15+
github.com/opendecree/decree/sdk/retry v0.0.0 // indirect
16+
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
17+
github.com/sagikazarmark/locafero v0.4.0 // indirect
18+
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
19+
github.com/sourcegraph/conc v0.3.0 // indirect
20+
github.com/spf13/afero v1.11.0 // indirect
21+
github.com/spf13/cast v1.6.0 // indirect
22+
github.com/spf13/pflag v1.0.5 // indirect
23+
github.com/subosito/gotenv v1.6.0 // indirect
24+
go.uber.org/atomic v1.9.0 // indirect
25+
go.uber.org/multierr v1.9.0 // indirect
26+
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
27+
golang.org/x/sys v0.18.0 // indirect
28+
golang.org/x/text v0.14.0 // indirect
29+
gopkg.in/ini.v1 v1.67.0 // indirect
30+
gopkg.in/yaml.v3 v3.0.1 // indirect
31+
)
32+
33+
replace github.com/opendecree/decree/sdk/configclient => ../../../sdk/configclient
34+
35+
replace github.com/opendecree/decree/sdk/retry => ../../../sdk/retry

sdk/contrib/viper/go.sum

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
2+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3+
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
4+
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
5+
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
6+
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
7+
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
8+
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
9+
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
10+
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
11+
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
12+
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
13+
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
14+
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
15+
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
16+
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
17+
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
18+
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
19+
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
20+
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
21+
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
22+
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
23+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
24+
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
25+
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
26+
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
27+
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
28+
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
29+
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
30+
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
31+
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
32+
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
33+
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
34+
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
35+
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
36+
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
37+
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
38+
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
39+
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
40+
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
41+
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
42+
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
43+
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
44+
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
45+
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
46+
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
47+
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
48+
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
49+
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
50+
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
51+
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
52+
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
53+
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
54+
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
55+
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
56+
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
57+
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
58+
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
59+
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
60+
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
61+
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
62+
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
63+
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
64+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
65+
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
66+
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
67+
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
68+
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
69+
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
70+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
71+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

sdk/contrib/viper/viper.go

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
// Package vipercontrib provides a Viper remote config provider backed by a
2+
// decree configclient. It allows Go applications using Viper for configuration
3+
// to transparently read values from OpenDecree.
4+
//
5+
// Usage:
6+
//
7+
// transport := grpctransport.NewConfigTransport(conn)
8+
// client := configclient.New(transport)
9+
// p := vipercontrib.New(client, "my-tenant")
10+
// vipercontrib.Register("decree", p)
11+
//
12+
// // Then use Viper normally:
13+
// viper.SetConfigType("json")
14+
// viper.AddRemoteProvider("decree", "", "")
15+
// viper.ReadRemoteConfig()
16+
package vipercontrib
17+
18+
import (
19+
"bytes"
20+
"context"
21+
"encoding/json"
22+
"io"
23+
"time"
24+
25+
"github.com/opendecree/decree/sdk/configclient"
26+
"github.com/spf13/viper"
27+
)
28+
29+
// Provider is a Viper remote config provider backed by a decree configclient.
30+
// It fetches all config fields for the tenant and maps them to Viper keys.
31+
type Provider struct {
32+
client *configclient.Client
33+
tenantID string
34+
timeout time.Duration
35+
}
36+
37+
// New creates a new Provider for the given tenant.
38+
func New(client *configclient.Client, tenantID string, opts ...Option) *Provider {
39+
p := &Provider{
40+
client: client,
41+
tenantID: tenantID,
42+
timeout: 5 * time.Second,
43+
}
44+
for _, opt := range opts {
45+
opt(p)
46+
}
47+
return p
48+
}
49+
50+
// Option configures a Provider.
51+
type Option func(*Provider)
52+
53+
// WithTimeout sets the request timeout for config reads.
54+
func WithTimeout(d time.Duration) Option {
55+
return func(p *Provider) { p.timeout = d }
56+
}
57+
58+
// Register registers this provider with Viper under the given provider name and
59+
// appends that name to viper.SupportedRemoteProviders so that
60+
// viper.AddRemoteProvider accepts it.
61+
//
62+
// Call Register before viper.AddRemoteProvider.
63+
func Register(name string, p *Provider) {
64+
viper.SupportedRemoteProviders = append(viper.SupportedRemoteProviders, name)
65+
viper.RemoteConfig = &configProvider{p: p}
66+
}
67+
68+
type configProvider struct {
69+
p *Provider
70+
}
71+
72+
func (cp *configProvider) Get(_ viper.RemoteProvider) (io.Reader, error) {
73+
return cp.p.fetch()
74+
}
75+
76+
func (cp *configProvider) Watch(_ viper.RemoteProvider) (io.Reader, error) {
77+
return cp.p.fetch()
78+
}
79+
80+
func (cp *configProvider) WatchChannel(_ viper.RemoteProvider) (<-chan *viper.RemoteResponse, chan bool) {
81+
ch := make(chan *viper.RemoteResponse)
82+
quit := make(chan bool)
83+
go func() {
84+
for {
85+
select {
86+
case <-quit:
87+
return
88+
default:
89+
r, err := cp.p.fetch()
90+
if err == nil {
91+
b, _ := io.ReadAll(r)
92+
ch <- &viper.RemoteResponse{Value: b}
93+
}
94+
time.Sleep(30 * time.Second)
95+
}
96+
}
97+
}()
98+
return ch, quit
99+
}
100+
101+
// fetch retrieves all config values for the tenant and encodes them as JSON.
102+
// Viper parses JSON from remote providers. Field paths with dot separators
103+
// (e.g. "app.name") are converted to nested JSON objects so that Viper's
104+
// hierarchical key lookup works correctly:
105+
//
106+
// "app.name" → {"app": {"name": "..."}}
107+
func (p *Provider) fetch() (io.Reader, error) {
108+
ctx, cancel := context.WithTimeout(context.Background(), p.timeout)
109+
defer cancel()
110+
111+
m, err := p.client.GetAll(ctx, p.tenantID)
112+
if err != nil {
113+
return nil, err
114+
}
115+
116+
nested := toNestedMap(m)
117+
118+
b, err := json.Marshal(nested)
119+
if err != nil {
120+
return nil, err
121+
}
122+
return bytes.NewReader(b), nil
123+
}
124+
125+
// toNestedMap converts a flat map with dot-separated keys into a nested
126+
// map[string]any suitable for Viper's hierarchical key lookup.
127+
//
128+
// Example:
129+
//
130+
// {"app.name": "myapp", "app.debug": "true"} →
131+
// {"app": {"name": "myapp", "debug": "true"}}
132+
func toNestedMap(flat map[string]string) map[string]any {
133+
out := make(map[string]any)
134+
for key, val := range flat {
135+
setNested(out, key, val)
136+
}
137+
return out
138+
}
139+
140+
// setNested inserts val into m at the path described by the dot-separated key.
141+
func setNested(m map[string]any, key, val string) {
142+
for {
143+
dot := -1
144+
for i := 0; i < len(key); i++ {
145+
if key[i] == '.' {
146+
dot = i
147+
break
148+
}
149+
}
150+
if dot < 0 {
151+
m[key] = val
152+
return
153+
}
154+
prefix := key[:dot]
155+
key = key[dot+1:]
156+
child, ok := m[prefix]
157+
if !ok {
158+
next := make(map[string]any)
159+
m[prefix] = next
160+
m = next
161+
continue
162+
}
163+
next, ok := child.(map[string]any)
164+
if !ok {
165+
// Conflict: existing leaf at prefix — prefer the deeper key.
166+
next = make(map[string]any)
167+
m[prefix] = next
168+
}
169+
m = next
170+
}
171+
}

0 commit comments

Comments
 (0)