Skip to content

Commit 78a0d56

Browse files
authored
feat(sdk): add contrib/envconfig struct field adapter
- Added sdk/contrib/envconfig — reads decree struct tags and fetches each field value from the configclient in one Process call. - Supports string, bool, int64, float64, and time.Duration; untagged and decree:"-" fields are skipped. Closes #16
1 parent c1d4e71 commit 78a0d56

6 files changed

Lines changed: 292 additions & 2 deletions

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,7 @@ jobs:
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+=($!)
260260
cd sdk/contrib/viper && go test ./... -count=1 -coverprofile=../../cov-contrib-viper.out -covermode=atomic & pids+=($!)
261+
cd sdk/contrib/envconfig && go test ./... -count=1 -coverprofile=../../cov-contrib-envconfig.out -covermode=atomic & pids+=($!)
261262
cd cmd/decree && go test ./... -count=1 -coverprofile=../../cov-decree.out -covermode=atomic & pids+=($!)
262263
fail=0
263264
for pid in "${pids[@]}"; do wait "$pid" || fail=1; done
@@ -277,7 +278,7 @@ jobs:
277278
- name: Merge coverage profiles
278279
run: |
279280
echo "mode: atomic" > coverage.out
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
281+
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-contrib-envconfig.out cov-decree.out; do
281282
[ -f "$f" ] && grep -v "^mode:" "$f" >> coverage.out || true
282283
done
283284

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 sdk/contrib/viper
25+
SDK_MODULES := sdk/retry sdk/configclient sdk/adminclient sdk/configwatcher sdk/grpctransport sdk/tools sdk/contrib/viper sdk/contrib/envconfig
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/envconfig/README.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# envconfig
2+
3+
> **Alpha** — API subject to change.
4+
5+
Adapter that populates Go struct fields from OpenDecree config values using struct tags.
6+
7+
## Install
8+
9+
```bash
10+
go get github.com/opendecree/decree/sdk/contrib/envconfig
11+
```
12+
13+
## Usage
14+
15+
Tag struct fields with `decree:"<field-path>"`:
16+
17+
```go
18+
type AppConfig struct {
19+
Name string `decree:"app.name"`
20+
Debug bool `decree:"app.debug"`
21+
Count int64 `decree:"app.count"`
22+
Rate float64 `decree:"app.rate"`
23+
Timeout time.Duration `decree:"jobs.timeout"`
24+
}
25+
```
26+
27+
Then call `Process` to populate from the remote config:
28+
29+
```go
30+
var cfg AppConfig
31+
if err := envconfig.Process(ctx, client, tenantID, &cfg); err != nil {
32+
log.Fatal(err)
33+
}
34+
fmt.Println(cfg.Name) // value from OpenDecree
35+
```
36+
37+
## Supported types
38+
39+
| Go type | decree field type |
40+
|------------------|------------------|
41+
| `string` | `string` |
42+
| `bool` | `bool` |
43+
| `int`, `int64` | `integer` |
44+
| `float64` | `number` |
45+
| `time.Duration` | `duration` |
46+
47+
Fields with no `decree` tag or tagged `decree:"-"` are skipped.
48+
49+
## Limitations
50+
51+
- Read-only: `Process` only reads values; writing back is not supported.
52+
- One call per tagged field: each field triggers one `GetField` RPC.

sdk/contrib/envconfig/envconfig.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Package envconfig populates struct fields from OpenDecree config values.
2+
//
3+
// Fields are mapped via the `decree` struct tag:
4+
//
5+
// type AppConfig struct {
6+
// Name string `decree:"app.name"`
7+
// Debug bool `decree:"app.debug"`
8+
// Timeout time.Duration `decree:"jobs.timeout"`
9+
// }
10+
package envconfig
11+
12+
import (
13+
"context"
14+
"fmt"
15+
"reflect"
16+
"time"
17+
18+
"github.com/opendecree/decree/sdk/configclient"
19+
)
20+
21+
// Process reads all fields tagged with `decree:"<field-path>"` from the struct
22+
// pointed to by v and fetches their values from the decree config.
23+
func Process(ctx context.Context, client *configclient.Client, tenantID string, v any) error {
24+
rv := reflect.ValueOf(v)
25+
if rv.Kind() != reflect.Ptr || rv.IsNil() || rv.Elem().Kind() != reflect.Struct {
26+
return fmt.Errorf("envconfig: v must be a non-nil pointer to a struct, got %T", v)
27+
}
28+
rv = rv.Elem()
29+
rt := rv.Type()
30+
31+
for i := range rv.NumField() {
32+
field := rt.Field(i)
33+
tag := field.Tag.Get("decree")
34+
if tag == "" || tag == "-" {
35+
continue
36+
}
37+
fv := rv.Field(i)
38+
if !fv.CanSet() {
39+
continue
40+
}
41+
if err := setField(ctx, client, tenantID, tag, fv); err != nil {
42+
return fmt.Errorf("envconfig: field %s (decree:%q): %w", field.Name, tag, err)
43+
}
44+
}
45+
return nil
46+
}
47+
48+
var durationType = reflect.TypeOf(time.Duration(0))
49+
50+
func setField(ctx context.Context, client *configclient.Client, tenantID, path string, fv reflect.Value) error {
51+
switch {
52+
case fv.Type() == durationType:
53+
v, err := client.GetDuration(ctx, tenantID, path)
54+
if err != nil {
55+
return err
56+
}
57+
fv.SetInt(int64(v))
58+
case fv.Kind() == reflect.String:
59+
v, err := client.GetString(ctx, tenantID, path)
60+
if err != nil {
61+
return err
62+
}
63+
fv.SetString(v)
64+
case fv.Kind() == reflect.Bool:
65+
v, err := client.GetBool(ctx, tenantID, path)
66+
if err != nil {
67+
return err
68+
}
69+
fv.SetBool(v)
70+
case fv.Kind() >= reflect.Int && fv.Kind() <= reflect.Int64:
71+
v, err := client.GetInt(ctx, tenantID, path)
72+
if err != nil {
73+
return err
74+
}
75+
fv.SetInt(v)
76+
case fv.Kind() == reflect.Float64 || fv.Kind() == reflect.Float32:
77+
v, err := client.GetFloat(ctx, tenantID, path)
78+
if err != nil {
79+
return err
80+
}
81+
fv.SetFloat(v)
82+
default:
83+
return fmt.Errorf("unsupported field type %s", fv.Type())
84+
}
85+
return nil
86+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package envconfig_test
2+
3+
import (
4+
"context"
5+
"errors"
6+
"testing"
7+
"time"
8+
9+
"github.com/opendecree/decree/sdk/configclient"
10+
envconfig "github.com/opendecree/decree/sdk/contrib/envconfig"
11+
)
12+
13+
// stubTransport returns predetermined field values by path.
14+
type stubTransport struct {
15+
values map[string]*configclient.TypedValue
16+
err error
17+
}
18+
19+
func (s *stubTransport) GetField(_ context.Context, req *configclient.GetFieldRequest) (*configclient.GetFieldResponse, error) {
20+
if s.err != nil {
21+
return nil, s.err
22+
}
23+
v, ok := s.values[req.FieldPath]
24+
if !ok {
25+
return nil, errors.New("field not found: " + req.FieldPath)
26+
}
27+
return &configclient.GetFieldResponse{FieldPath: req.FieldPath, Value: v}, nil
28+
}
29+
30+
func (s *stubTransport) GetConfig(_ context.Context, _ *configclient.GetConfigRequest) (*configclient.GetConfigResponse, error) {
31+
return &configclient.GetConfigResponse{}, nil
32+
}
33+
34+
func (s *stubTransport) GetFields(_ context.Context, req *configclient.GetFieldsRequest) (*configclient.GetFieldsResponse, error) {
35+
vals := make([]configclient.ConfigValue, 0, len(req.FieldPaths))
36+
for _, p := range req.FieldPaths {
37+
v, ok := s.values[p]
38+
if ok {
39+
vals = append(vals, configclient.ConfigValue{FieldPath: p, Value: v})
40+
}
41+
}
42+
return &configclient.GetFieldsResponse{Values: vals}, nil
43+
}
44+
45+
func (s *stubTransport) SetField(_ context.Context, _ *configclient.SetFieldRequest) (*configclient.SetFieldResponse, error) {
46+
return &configclient.SetFieldResponse{}, nil
47+
}
48+
49+
func (s *stubTransport) SetFields(_ context.Context, _ *configclient.SetFieldsRequest) (*configclient.SetFieldsResponse, error) {
50+
return &configclient.SetFieldsResponse{}, nil
51+
}
52+
53+
func (s *stubTransport) Subscribe(_ context.Context, _ *configclient.SubscribeRequest) (configclient.Subscription, error) {
54+
return nil, nil
55+
}
56+
57+
func newClient(values map[string]*configclient.TypedValue) *configclient.Client {
58+
return configclient.New(&stubTransport{values: values})
59+
}
60+
61+
func TestProcess_AllTypes(t *testing.T) {
62+
client := newClient(map[string]*configclient.TypedValue{
63+
"app.name": configclient.StringVal("myapp"),
64+
"app.debug": configclient.BoolVal(true),
65+
"app.count": configclient.IntVal(42),
66+
"app.rate": configclient.FloatVal(1.5),
67+
"app.timeout": configclient.DurationVal(5 * time.Second),
68+
})
69+
70+
type Config struct {
71+
Name string `decree:"app.name"`
72+
Debug bool `decree:"app.debug"`
73+
Count int64 `decree:"app.count"`
74+
Rate float64 `decree:"app.rate"`
75+
Timeout time.Duration `decree:"app.timeout"`
76+
}
77+
78+
var cfg Config
79+
if err := envconfig.Process(context.Background(), client, "t1", &cfg); err != nil {
80+
t.Fatalf("Process: %v", err)
81+
}
82+
if cfg.Name != "myapp" {
83+
t.Errorf("Name = %q, want %q", cfg.Name, "myapp")
84+
}
85+
if !cfg.Debug {
86+
t.Error("Debug = false, want true")
87+
}
88+
if cfg.Count != 42 {
89+
t.Errorf("Count = %d, want 42", cfg.Count)
90+
}
91+
if cfg.Rate != 1.5 {
92+
t.Errorf("Rate = %f, want 1.5", cfg.Rate)
93+
}
94+
if cfg.Timeout != 5*time.Second {
95+
t.Errorf("Timeout = %v, want 5s", cfg.Timeout)
96+
}
97+
}
98+
99+
func TestProcess_SkipsUntagged(t *testing.T) {
100+
client := newClient(map[string]*configclient.TypedValue{})
101+
102+
type Config struct {
103+
Name string `decree:"app.name"`
104+
Untagged string
105+
Skipped string `decree:"-"`
106+
unexported string //nolint:unused
107+
}
108+
109+
_ = client
110+
// Process should not error for untagged or "-"-tagged or unexported fields
111+
client2 := newClient(map[string]*configclient.TypedValue{
112+
"app.name": configclient.StringVal("x"),
113+
})
114+
var cfg Config
115+
if err := envconfig.Process(context.Background(), client2, "t1", &cfg); err != nil {
116+
t.Fatalf("unexpected error: %v", err)
117+
}
118+
}
119+
120+
func TestProcess_NonPointerError(t *testing.T) {
121+
type Config struct{}
122+
err := envconfig.Process(context.Background(), nil, "", Config{})
123+
if err == nil {
124+
t.Error("expected error for non-pointer, got nil")
125+
}
126+
}
127+
128+
func TestProcess_FieldError(t *testing.T) {
129+
stub := &stubTransport{err: errors.New("transport error")}
130+
client := configclient.New(stub)
131+
132+
type Config struct {
133+
Name string `decree:"app.name"`
134+
}
135+
var cfg Config
136+
err := envconfig.Process(context.Background(), client, "t1", &cfg)
137+
if err == nil {
138+
t.Error("expected error, got nil")
139+
}
140+
}

sdk/contrib/envconfig/go.mod

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
module github.com/opendecree/decree/sdk/contrib/envconfig
2+
3+
go 1.22.0
4+
5+
require github.com/opendecree/decree/sdk/configclient v0.1.2
6+
7+
require github.com/opendecree/decree/sdk/retry v0.0.0 // indirect
8+
9+
replace github.com/opendecree/decree/sdk/configclient => ../../../sdk/configclient
10+
11+
replace github.com/opendecree/decree/sdk/retry => ../../../sdk/retry

0 commit comments

Comments
 (0)