Skip to content

Commit 7e161de

Browse files
zeevdrclaude
andcommitted
feat(sdk): add contrib/koanf config provider
Implements a koanf v2 Provider backed by configclient.GetAll(). Supports Read(), ReadBytes() (error), Watch() (30s polling), and a WithTimeout option. Includes six unit tests with a fakeTransport. Closes #15 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 1bab8ee commit 7e161de

5 files changed

Lines changed: 424 additions & 0 deletions

File tree

sdk/contrib/koanf/README.md

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# contrib/koanf
2+
3+
A [koanf](https://github.com/knadh/koanf) provider for OpenDecree configuration.
4+
5+
> **Alpha** — API subject to change.
6+
7+
## Installation
8+
9+
```bash
10+
go get github.com/opendecree/decree/sdk/contrib/koanf
11+
```
12+
13+
You also need a transport to connect to an OpenDecree server. The gRPC
14+
transport lives in the sibling `sdk/grpctransport` module:
15+
16+
```bash
17+
go get github.com/opendecree/decree/sdk/grpctransport
18+
```
19+
20+
## Usage
21+
22+
```go
23+
import (
24+
"log"
25+
26+
"github.com/knadh/koanf/v2"
27+
koanfcontrib "github.com/opendecree/decree/sdk/contrib/koanf"
28+
"github.com/opendecree/decree/sdk/configclient"
29+
"github.com/opendecree/decree/sdk/grpctransport"
30+
"google.golang.org/grpc"
31+
)
32+
33+
func main() {
34+
conn, err := grpc.NewClient("decree-server:443", grpc.WithTransportCredentials(...))
35+
if err != nil {
36+
log.Fatal(err)
37+
}
38+
defer conn.Close()
39+
40+
transport := grpctransport.NewConfigTransport(conn)
41+
client := configclient.New(transport)
42+
43+
provider := koanfcontrib.New(client, "my-tenant")
44+
45+
k := koanf.New(".")
46+
if err := k.Load(provider, nil); err != nil {
47+
log.Fatal(err)
48+
}
49+
50+
log.Println("app.name =", k.String("app.name"))
51+
}
52+
```
53+
54+
## Options
55+
56+
### `WithTimeout(d time.Duration)`
57+
58+
Sets the per-call context timeout for `Read`. Default: 5 seconds.
59+
60+
```go
61+
provider := koanfcontrib.New(client, "my-tenant",
62+
koanfcontrib.WithTimeout(10*time.Second),
63+
)
64+
```
65+
66+
## Watching for changes
67+
68+
The provider exposes a `Watch` method that polls for configuration changes at a
69+
30-second interval. Call it after loading to keep koanf in sync:
70+
71+
```go
72+
provider := koanfcontrib.New(client, "my-tenant")
73+
k := koanf.New(".")
74+
75+
if err := k.Load(provider, nil); err != nil {
76+
log.Fatal(err)
77+
}
78+
79+
provider.Watch(func(event interface{}, err error) {
80+
if err != nil {
81+
log.Println("watch error:", err)
82+
return
83+
}
84+
// Re-load config on each tick.
85+
if err := k.Load(provider, nil); err != nil {
86+
log.Println("reload error:", err)
87+
}
88+
})
89+
```
90+
91+
## Notes
92+
93+
- Configuration values are returned as a flat `map[string]interface{}` keyed by
94+
field path (e.g. `"app.name"`). koanf treats the delimiter (`.` by default)
95+
as a nesting separator, so `k.String("app.name")` accesses the nested path.
96+
- `ReadBytes` is not supported. Always pass `nil` as the parser argument to
97+
`koanf.Load`.
98+
- For real-time change notifications (instead of polling), subscribe to the
99+
decree change stream using `configwatcher`.

sdk/contrib/koanf/go.mod

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
module github.com/opendecree/decree/sdk/contrib/koanf
2+
3+
go 1.22.0
4+
5+
require (
6+
github.com/knadh/koanf/v2 v2.1.2
7+
github.com/opendecree/decree/sdk/configclient v0.1.2
8+
)
9+
10+
require (
11+
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
12+
github.com/knadh/koanf/maps v0.1.1 // indirect
13+
github.com/mitchellh/copystructure v1.2.0 // indirect
14+
github.com/mitchellh/reflectwalk v1.0.2 // indirect
15+
github.com/opendecree/decree/sdk/retry v0.0.0 // indirect
16+
)
17+
18+
replace github.com/opendecree/decree/sdk/configclient => ../../../sdk/configclient
19+
20+
replace github.com/opendecree/decree/sdk/retry => ../../../sdk/retry

sdk/contrib/koanf/go.sum

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
2+
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
3+
github.com/knadh/koanf/maps v0.1.1 h1:G5TjmUh2D7G2YWf5SQQqSiHRJEjaicvU0KpypqB3NIs=
4+
github.com/knadh/koanf/maps v0.1.1/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
5+
github.com/knadh/koanf/v2 v2.1.2 h1:I2rtLRqXRy1p01m/utEtpZSSA6dcJbgGVuE27kW2PzQ=
6+
github.com/knadh/koanf/v2 v2.1.2/go.mod h1:Gphfaen0q1Fc1HTgJgSTC4oRX9R2R5ErYMZJy8fLJBo=
7+
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
8+
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
9+
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
10+
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=

sdk/contrib/koanf/koanf.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
// Package koanfcontrib provides a koanf provider backed by an OpenDecree
2+
// configclient. It implements the koanf.Provider interface so that decree
3+
// configuration values can be loaded directly into a koanf instance.
4+
//
5+
// Usage:
6+
//
7+
// provider := koanfcontrib.New(client, "my-tenant")
8+
// k := koanf.New(".")
9+
// if err := k.Load(provider, nil); err != nil {
10+
// log.Fatal(err)
11+
// }
12+
package koanfcontrib
13+
14+
import (
15+
"context"
16+
"fmt"
17+
"time"
18+
19+
"github.com/knadh/koanf/v2"
20+
"github.com/opendecree/decree/sdk/configclient"
21+
)
22+
23+
// Provider is a koanf provider backed by a decree configclient.
24+
// It fetches all configuration values for a tenant via [configclient.Client.GetAll]
25+
// and exposes them as a flat map of field-path keys.
26+
type Provider struct {
27+
client *configclient.Client
28+
tenantID string
29+
timeout time.Duration
30+
}
31+
32+
// New creates a Provider for the given tenant.
33+
// opts can be used to override defaults such as the per-call timeout.
34+
func New(client *configclient.Client, tenantID string, opts ...Option) *Provider {
35+
p := &Provider{client: client, tenantID: tenantID, timeout: 5 * time.Second}
36+
for _, o := range opts {
37+
o(p)
38+
}
39+
return p
40+
}
41+
42+
// Option configures a Provider.
43+
type Option func(*Provider)
44+
45+
// WithTimeout sets the per-call context timeout used by Read.
46+
// The default timeout is 5 seconds.
47+
func WithTimeout(d time.Duration) Option {
48+
return func(p *Provider) { p.timeout = d }
49+
}
50+
51+
// Read fetches all configuration values for the tenant from OpenDecree and
52+
// returns them as a flat map[string]interface{} keyed by field path.
53+
// Implements koanf.Provider.
54+
func (p *Provider) Read() (map[string]interface{}, error) {
55+
ctx, cancel := context.WithTimeout(context.Background(), p.timeout)
56+
defer cancel()
57+
all, err := p.client.GetAll(ctx, p.tenantID)
58+
if err != nil {
59+
return nil, err
60+
}
61+
m := make(map[string]interface{}, len(all))
62+
for k, v := range all {
63+
m[k] = v
64+
}
65+
return m, nil
66+
}
67+
68+
// ReadBytes is not supported by this provider. Use Read instead (pass nil as
69+
// the Parser argument to koanf.Load).
70+
// Implements koanf.Provider.
71+
func (p *Provider) ReadBytes() ([]byte, error) {
72+
return nil, fmt.Errorf("koanfcontrib: ReadBytes not supported; use Read with a nil parser")
73+
}
74+
75+
// Watch polls for configuration changes at a 30-second interval by invoking
76+
// the callback, which causes koanf to re-load the provider.
77+
// The callback receives a nil event and nil error on each tick.
78+
//
79+
// Watch launches a background goroutine and returns immediately.
80+
// This is a convenience method — it is not part of the koanf.Provider interface.
81+
func (p *Provider) Watch(cb func(event interface{}, err error)) error {
82+
go func() {
83+
ticker := time.NewTicker(30 * time.Second)
84+
defer ticker.Stop()
85+
for range ticker.C {
86+
cb(nil, nil)
87+
}
88+
}()
89+
return nil
90+
}
91+
92+
// Ensure *Provider implements koanf.Provider at compile time.
93+
var _ koanf.Provider = (*Provider)(nil)

0 commit comments

Comments
 (0)