Skip to content

Commit 5e0bd5f

Browse files
committed
feat(configwatcher): add NewGroup/Fill for struct-based field watching
Closes #18
1 parent 86cfde6 commit 5e0bd5f

2 files changed

Lines changed: 228 additions & 0 deletions

File tree

sdk/configwatcher/fieldgroups.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package configwatcher
2+
3+
import (
4+
"fmt"
5+
"reflect"
6+
"time"
7+
)
8+
9+
// Group registers a set of watched fields mapped from a struct's `decree` tags.
10+
// Use [Watcher.NewGroup] to create one.
11+
type Group struct {
12+
getters []func(rv reflect.Value) // one per tagged field
13+
typ reflect.Type // struct type for validation
14+
}
15+
16+
// NewGroup registers all `decree`-tagged fields in the struct pointed to by s
17+
// with the watcher and returns a Group. The struct pointer must remain valid
18+
// for the lifetime of the watcher.
19+
//
20+
// Example:
21+
//
22+
// type AppConfig struct {
23+
// Name string `decree:"app.name"`
24+
// Debug bool `decree:"app.debug"`
25+
// }
26+
// g, err := w.NewGroup(ctx, &AppConfig{})
27+
func (w *Watcher) NewGroup(s any) (*Group, error) {
28+
rv := reflect.ValueOf(s)
29+
if rv.Kind() != reflect.Ptr || rv.IsNil() || rv.Elem().Kind() != reflect.Struct {
30+
return nil, fmt.Errorf("configwatcher: NewGroup: s must be a non-nil pointer to a struct, got %T", s)
31+
}
32+
rv = rv.Elem()
33+
rt := rv.Type()
34+
35+
g := &Group{typ: rt}
36+
37+
for i := range rv.NumField() {
38+
field := rt.Field(i)
39+
tag := field.Tag.Get("decree")
40+
if tag == "" || tag == "-" {
41+
continue
42+
}
43+
fv := rv.Field(i)
44+
if !fv.CanSet() {
45+
continue
46+
}
47+
48+
getter, err := registerGroupField(w, tag, fv)
49+
if err != nil {
50+
return nil, fmt.Errorf("configwatcher: NewGroup: field %s (decree:%q): %w", field.Name, tag, err)
51+
}
52+
idx := i
53+
g.getters = append(g.getters, func(target reflect.Value) {
54+
getter(target.Field(idx))
55+
})
56+
}
57+
return g, nil
58+
}
59+
60+
// Fill populates the struct pointed to by s with the current values of all
61+
// watched fields. s must be the same type that was passed to NewGroup.
62+
func (g *Group) Fill(s any) error {
63+
rv := reflect.ValueOf(s)
64+
if rv.Kind() != reflect.Ptr || rv.IsNil() || rv.Elem().Kind() != reflect.Struct {
65+
return fmt.Errorf("configwatcher: Group.Fill: s must be a non-nil pointer to a struct")
66+
}
67+
rv = rv.Elem()
68+
if rv.Type() != g.typ {
69+
return fmt.Errorf("configwatcher: Group.Fill: type mismatch: got %s, want %s", rv.Type(), g.typ)
70+
}
71+
for _, get := range g.getters {
72+
get(rv)
73+
}
74+
return nil
75+
}
76+
77+
var durType = reflect.TypeOf(time.Duration(0))
78+
79+
func registerGroupField(w *Watcher, path string, fv reflect.Value) (func(dst reflect.Value), error) {
80+
switch {
81+
case fv.Type() == durType:
82+
val, err := w.Duration(path, 0)
83+
if err != nil {
84+
return nil, err
85+
}
86+
return func(dst reflect.Value) { dst.SetInt(int64(val.Get())) }, nil
87+
88+
case fv.Kind() == reflect.String:
89+
val, err := w.String(path, "")
90+
if err != nil {
91+
return nil, err
92+
}
93+
return func(dst reflect.Value) { dst.SetString(val.Get()) }, nil
94+
95+
case fv.Kind() == reflect.Bool:
96+
val, err := w.Bool(path, false)
97+
if err != nil {
98+
return nil, err
99+
}
100+
return func(dst reflect.Value) { dst.SetBool(val.Get()) }, nil
101+
102+
case fv.Kind() >= reflect.Int && fv.Kind() <= reflect.Int64:
103+
val, err := w.Int(path, 0)
104+
if err != nil {
105+
return nil, err
106+
}
107+
return func(dst reflect.Value) { dst.SetInt(val.Get()) }, nil
108+
109+
case fv.Kind() == reflect.Float64 || fv.Kind() == reflect.Float32:
110+
val, err := w.Float(path, 0)
111+
if err != nil {
112+
return nil, err
113+
}
114+
return func(dst reflect.Value) { dst.SetFloat(val.Get()) }, nil
115+
116+
default:
117+
return nil, fmt.Errorf("unsupported field type %s", fv.Type())
118+
}
119+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package configwatcher
2+
3+
import (
4+
"context"
5+
"testing"
6+
"time"
7+
8+
"github.com/opendecree/decree/sdk/configclient"
9+
)
10+
11+
func TestNewGroup_FillAllTypes(t *testing.T) {
12+
tr := &mockTransport{
13+
getConfigFn: func(_ context.Context, _ *configclient.GetConfigRequest) (*configclient.GetConfigResponse, error) {
14+
return &configclient.GetConfigResponse{
15+
Values: []configclient.ConfigValue{
16+
{FieldPath: "app.name", Value: configclient.StringVal("myapp")},
17+
{FieldPath: "app.debug", Value: configclient.BoolVal(true)},
18+
{FieldPath: "app.count", Value: configclient.IntVal(42)},
19+
{FieldPath: "app.rate", Value: configclient.FloatVal(1.5)},
20+
{FieldPath: "app.timeout", Value: configclient.DurationVal(5 * time.Second)},
21+
},
22+
}, nil
23+
},
24+
subscribeFn: func(ctx context.Context, _ *configclient.SubscribeRequest) (configclient.Subscription, error) {
25+
return newMockSubscription(ctx), nil
26+
},
27+
}
28+
29+
type Config struct {
30+
Name string `decree:"app.name"`
31+
Debug bool `decree:"app.debug"`
32+
Count int64 `decree:"app.count"`
33+
Rate float64 `decree:"app.rate"`
34+
Timeout time.Duration `decree:"app.timeout"`
35+
Ignored string
36+
Skipped string `decree:"-"`
37+
}
38+
39+
w := New(tr, "t1")
40+
g, err := w.NewGroup(&Config{})
41+
if err != nil {
42+
t.Fatalf("NewGroup: %v", err)
43+
}
44+
if err := w.Start(context.Background()); err != nil {
45+
t.Fatalf("Start: %v", err)
46+
}
47+
defer w.Close()
48+
49+
var cfg Config
50+
if err := g.Fill(&cfg); err != nil {
51+
t.Fatalf("Fill: %v", err)
52+
}
53+
54+
if cfg.Name != "myapp" {
55+
t.Errorf("Name = %q, want %q", cfg.Name, "myapp")
56+
}
57+
if !cfg.Debug {
58+
t.Error("Debug = false, want true")
59+
}
60+
if cfg.Count != 42 {
61+
t.Errorf("Count = %d, want 42", cfg.Count)
62+
}
63+
if cfg.Rate != 1.5 {
64+
t.Errorf("Rate = %f, want 1.5", cfg.Rate)
65+
}
66+
if cfg.Timeout != 5*time.Second {
67+
t.Errorf("Timeout = %v, want 5s", cfg.Timeout)
68+
}
69+
}
70+
71+
func TestNewGroup_NonPointerError(t *testing.T) {
72+
tr := &mockTransport{
73+
getConfigFn: func(_ context.Context, _ *configclient.GetConfigRequest) (*configclient.GetConfigResponse, error) {
74+
return &configclient.GetConfigResponse{}, nil
75+
},
76+
subscribeFn: func(ctx context.Context, _ *configclient.SubscribeRequest) (configclient.Subscription, error) {
77+
return newMockSubscription(ctx), nil
78+
},
79+
}
80+
w := New(tr, "t1")
81+
type S struct{}
82+
_, err := w.NewGroup(S{})
83+
if err == nil {
84+
t.Error("expected error for non-pointer, got nil")
85+
}
86+
}
87+
88+
func TestGroup_Fill_TypeMismatch(t *testing.T) {
89+
tr := &mockTransport{
90+
getConfigFn: func(_ context.Context, _ *configclient.GetConfigRequest) (*configclient.GetConfigResponse, error) {
91+
return &configclient.GetConfigResponse{}, nil
92+
},
93+
subscribeFn: func(ctx context.Context, _ *configclient.SubscribeRequest) (configclient.Subscription, error) {
94+
return newMockSubscription(ctx), nil
95+
},
96+
}
97+
w := New(tr, "t1")
98+
type A struct{ X string `decree:"x"` }
99+
type B struct{ X string `decree:"x"` }
100+
101+
g, err := w.NewGroup(&A{})
102+
if err != nil {
103+
t.Fatalf("NewGroup: %v", err)
104+
}
105+
var b B
106+
if err := g.Fill(&b); err == nil {
107+
t.Error("expected type mismatch error, got nil")
108+
}
109+
}

0 commit comments

Comments
 (0)