Skip to content

Commit 422461b

Browse files
committed
Add support for struct slices
1 parent e06932f commit 422461b

3 files changed

Lines changed: 283 additions & 1 deletion

File tree

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,38 @@ err = configstruct.Save("config.yaml", &conf)
6868

6969
```
7070

71+
## Struct slices (`[]struct`) via ENV and CLI (JSON)
72+
73+
`[]struct` fields can now be populated from `env` and `cli` tags using JSON.
74+
75+
```Go
76+
type Endpoint struct {
77+
User string `json:"user" yaml:"user"`
78+
Pass string `json:"pass" yaml:"pass"`
79+
URL string `json:"url" yaml:"url"`
80+
}
81+
82+
type Config struct {
83+
Endpoints []Endpoint `env:"MY_ENDPOINTS" cli:"endpoints" yaml:"endpoints" usage:"configured endpoints as JSON"`
84+
}
85+
```
86+
87+
Supported JSON formats:
88+
89+
- ENV (`MY_ENDPOINTS`):
90+
- JSON array (primary): `[{"user":"u1","pass":"p1","url":"https://a"},{"user":"u2","pass":"p2","url":"https://b"}]`
91+
- JSON object (optional shorthand for one entry): `{"user":"u1","pass":"p1","url":"https://a"}`
92+
- CLI (`-endpoints`):
93+
- repeated JSON object flags:
94+
- `-endpoints '{"user":"u1","pass":"p1","url":"https://a"}' -endpoints '{"user":"u2","pass":"p2","url":"https://b"}'`
95+
- single JSON array:
96+
- `-endpoints '[{"user":"u1","pass":"p1","url":"https://a"},{"user":"u2","pass":"p2","url":"https://b"}]'`
97+
98+
Precedence is unchanged:
99+
100+
- Default: CLI overrides ENV (`Parse`, `WithPrecedenceCli`).
101+
- `WithPrecedenceEnv()`: ENV overrides CLI.
102+
71103
## Usage with commands
72104
You can also define "commands" that can be used to execute callback functions.
73105
The program with global flags and a command `count` should be called like this:

configstruct.go

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
package configstruct // import "github.com/pteich/configstruct"
44

55
import (
6+
"encoding/json"
67
"flag"
78
"fmt"
89
"os"
@@ -117,6 +118,9 @@ func ParseWithFlagSet(flagSet *flag.FlagSet, cliArgs []string, c interface{}, op
117118
cli := field.Tag.Get("cli")
118119
cliAlt := field.Tag.Get("cliAlt")
119120
usage := field.Tag.Get("usage")
121+
structSliceValue := &structSliceFlag{
122+
target: valueRef.Elem().FieldByName(field.Name),
123+
}
120124

121125
setFlag := func(name string) error {
122126
switch field.Type.Kind() {
@@ -128,6 +132,12 @@ func ParseWithFlagSet(flagSet *flag.FlagSet, cliArgs []string, c interface{}, op
128132
flagSet.IntVar(valueRef.Elem().FieldByName(field.Name).Addr().Interface().(*int), name, int(value.Int()), usage)
129133
case reflect.Float64:
130134
flagSet.Float64Var(valueRef.Elem().FieldByName(field.Name).Addr().Interface().(*float64), name, value.Float(), usage)
135+
case reflect.Slice:
136+
if field.Type.Elem().Kind() == reflect.Struct {
137+
flagSet.Var(structSliceValue, name, usage)
138+
return nil
139+
}
140+
return fmt.Errorf("config cli type %s not implemented", field.Type.String())
131141
default:
132142
return fmt.Errorf("config cli type %s not implemented", field.Type.Kind())
133143
}
@@ -157,6 +167,9 @@ func ParseWithFlagSet(flagSet *flag.FlagSet, cliArgs []string, c interface{}, op
157167
for i := 0; i < confType.NumField(); i++ {
158168
field := confType.Field(i)
159169
env := field.Tag.Get("env")
170+
if env == "" {
171+
continue
172+
}
160173

161174
envValue, found := os.LookupEnv(env)
162175
if found {
@@ -178,8 +191,18 @@ func ParseWithFlagSet(flagSet *flag.FlagSet, cliArgs []string, c interface{}, op
178191
if err == nil {
179192
valueRef.Elem().FieldByName(field.Name).SetFloat(value)
180193
}
194+
case reflect.Slice:
195+
if field.Type.Elem().Kind() != reflect.Struct {
196+
return fmt.Errorf("config env type %s not implemented", field.Type.String())
197+
}
198+
199+
sliceValue, err := decodeStructSliceJSON(envValue, field.Type)
200+
if err != nil {
201+
return fmt.Errorf("could not parse env %s for field %s: %w", env, field.Name, err)
202+
}
203+
valueRef.Elem().FieldByName(field.Name).Set(sliceValue)
181204
default:
182-
return fmt.Errorf("config env type %s not implemented", field.Type.Name())
205+
return fmt.Errorf("config env type %s not implemented", field.Type.String())
183206
}
184207
}
185208
}
@@ -221,6 +244,63 @@ func ParseWithFlagSet(flagSet *flag.FlagSet, cliArgs []string, c interface{}, op
221244
return nil
222245
}
223246

247+
type structSliceFlag struct {
248+
target reflect.Value
249+
seen bool
250+
}
251+
252+
func (f *structSliceFlag) String() string {
253+
if !f.target.IsValid() {
254+
return ""
255+
}
256+
257+
b, err := json.Marshal(f.target.Interface())
258+
if err != nil {
259+
return ""
260+
}
261+
262+
return string(b)
263+
}
264+
265+
func (f *structSliceFlag) Set(value string) error {
266+
if !f.target.IsValid() || !f.target.CanSet() {
267+
return fmt.Errorf("slice field is not settable")
268+
}
269+
270+
sliceValue, err := decodeStructSliceJSON(value, f.target.Type())
271+
if err != nil {
272+
return err
273+
}
274+
275+
if !f.seen {
276+
f.target.Set(reflect.MakeSlice(f.target.Type(), 0, 0))
277+
f.seen = true
278+
}
279+
280+
f.target.Set(reflect.AppendSlice(f.target, sliceValue))
281+
return nil
282+
}
283+
284+
func decodeStructSliceJSON(value string, fieldType reflect.Type) (reflect.Value, error) {
285+
if fieldType.Kind() != reflect.Slice || fieldType.Elem().Kind() != reflect.Struct {
286+
return reflect.Value{}, fmt.Errorf("type %s is not a slice of structs", fieldType.String())
287+
}
288+
289+
sliceTarget := reflect.New(fieldType)
290+
if err := json.Unmarshal([]byte(value), sliceTarget.Interface()); err == nil {
291+
return sliceTarget.Elem(), nil
292+
}
293+
294+
elemTarget := reflect.New(fieldType.Elem())
295+
if err := json.Unmarshal([]byte(value), elemTarget.Interface()); err == nil {
296+
sliceValue := reflect.MakeSlice(fieldType, 1, 1)
297+
sliceValue.Index(0).Set(elemTarget.Elem())
298+
return sliceValue, nil
299+
}
300+
301+
return reflect.Value{}, fmt.Errorf("value is neither a JSON array nor a JSON object for %s", fieldType.String())
302+
}
303+
224304
type structFlag struct {
225305
name string
226306
description string

configstruct_test.go

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,16 @@ type testConfig struct {
1616
FloatValue float64 `env:"CONFIGSTRUCT_FLOAT" cli:"floatValue" usage:"float value"`
1717
}
1818

19+
type endpoint struct {
20+
User string `json:"user" yaml:"user"`
21+
Pass string `json:"pass" yaml:"pass"`
22+
URL string `json:"url" yaml:"url"`
23+
}
24+
25+
type endpointConfig struct {
26+
Endpoints []endpoint `env:"CONFIGSTRUCT_ENDPOINTS" cli:"endpoints" yaml:"endpoints"`
27+
}
28+
1929
func TestParse(t *testing.T) {
2030
t.Run("valid cli fields", func(t *testing.T) {
2131
cliArgs := []string{"command", "-hostname=localhost", "-port=8080", "-debug=true", "-floatValue=100.5"}
@@ -247,6 +257,166 @@ func TestParse(t *testing.T) {
247257
assert.Equal(t, tmpFile, conf.ConfigPath)
248258
})
249259

260+
t.Run("env json array for struct slice", func(t *testing.T) {
261+
os.Clearenv()
262+
os.Setenv("CONFIGSTRUCT_ENDPOINTS", `[{"user":"u1","pass":"p1","url":"https://a"},{"user":"u2","pass":"p2","url":"https://b"}]`)
263+
264+
cliArgs := []string{"command"}
265+
flagSet := flag.NewFlagSet(cliArgs[0], flag.ExitOnError)
266+
conf := endpointConfig{}
267+
268+
err := ParseWithFlagSet(flagSet, cliArgs, &conf)
269+
assert.NoError(t, err)
270+
assert.Len(t, conf.Endpoints, 2)
271+
assert.Equal(t, "u1", conf.Endpoints[0].User)
272+
assert.Equal(t, "https://b", conf.Endpoints[1].URL)
273+
})
274+
275+
t.Run("env json object for struct slice", func(t *testing.T) {
276+
os.Clearenv()
277+
os.Setenv("CONFIGSTRUCT_ENDPOINTS", `{"user":"u1","pass":"p1","url":"https://a"}`)
278+
279+
cliArgs := []string{"command"}
280+
flagSet := flag.NewFlagSet(cliArgs[0], flag.ExitOnError)
281+
conf := endpointConfig{}
282+
283+
err := ParseWithFlagSet(flagSet, cliArgs, &conf)
284+
assert.NoError(t, err)
285+
assert.Len(t, conf.Endpoints, 1)
286+
assert.Equal(t, "u1", conf.Endpoints[0].User)
287+
assert.Equal(t, "https://a", conf.Endpoints[0].URL)
288+
})
289+
290+
t.Run("cli repeated json objects for struct slice", func(t *testing.T) {
291+
os.Clearenv()
292+
cliArgs := []string{
293+
"command",
294+
`-endpoints={"user":"u1","pass":"p1","url":"https://a"}`,
295+
`-endpoints={"user":"u2","pass":"p2","url":"https://b"}`,
296+
}
297+
flagSet := flag.NewFlagSet(cliArgs[0], flag.ExitOnError)
298+
conf := endpointConfig{
299+
Endpoints: []endpoint{{User: "default", Pass: "default", URL: "https://default"}},
300+
}
301+
302+
err := ParseWithFlagSet(flagSet, cliArgs, &conf)
303+
assert.NoError(t, err)
304+
assert.Len(t, conf.Endpoints, 2)
305+
assert.Equal(t, "u1", conf.Endpoints[0].User)
306+
assert.Equal(t, "u2", conf.Endpoints[1].User)
307+
})
308+
309+
t.Run("cli single json array for struct slice", func(t *testing.T) {
310+
os.Clearenv()
311+
cliArgs := []string{
312+
"command",
313+
`-endpoints=[{"user":"u1","pass":"p1","url":"https://a"},{"user":"u2","pass":"p2","url":"https://b"}]`,
314+
}
315+
flagSet := flag.NewFlagSet(cliArgs[0], flag.ExitOnError)
316+
conf := endpointConfig{}
317+
318+
err := ParseWithFlagSet(flagSet, cliArgs, &conf)
319+
assert.NoError(t, err)
320+
assert.Len(t, conf.Endpoints, 2)
321+
assert.Equal(t, "u1", conf.Endpoints[0].User)
322+
assert.Equal(t, "u2", conf.Endpoints[1].User)
323+
})
324+
325+
t.Run("default precedence cli over env for struct slice", func(t *testing.T) {
326+
os.Clearenv()
327+
os.Setenv("CONFIGSTRUCT_ENDPOINTS", `[{"user":"env","pass":"env","url":"https://env"}]`)
328+
329+
cliArgs := []string{
330+
"command",
331+
`-endpoints={"user":"cli","pass":"cli","url":"https://cli"}`,
332+
}
333+
flagSet := flag.NewFlagSet(cliArgs[0], flag.ExitOnError)
334+
conf := endpointConfig{}
335+
336+
err := ParseWithFlagSet(flagSet, cliArgs, &conf)
337+
assert.NoError(t, err)
338+
assert.Len(t, conf.Endpoints, 1)
339+
assert.Equal(t, "cli", conf.Endpoints[0].User)
340+
})
341+
342+
t.Run("env precedence over cli for struct slice", func(t *testing.T) {
343+
os.Clearenv()
344+
os.Setenv("CONFIGSTRUCT_ENDPOINTS", `[{"user":"env","pass":"env","url":"https://env"}]`)
345+
346+
cliArgs := []string{
347+
"command",
348+
`-endpoints={"user":"cli","pass":"cli","url":"https://cli"}`,
349+
}
350+
flagSet := flag.NewFlagSet(cliArgs[0], flag.ExitOnError)
351+
conf := endpointConfig{}
352+
353+
err := ParseWithFlagSet(flagSet, cliArgs, &conf, WithPrecedenceEnv())
354+
assert.NoError(t, err)
355+
assert.Len(t, conf.Endpoints, 1)
356+
assert.Equal(t, "env", conf.Endpoints[0].User)
357+
})
358+
359+
t.Run("yaml struct slice overridden by cli", func(t *testing.T) {
360+
type Config struct {
361+
Endpoints []endpoint `yaml:"endpoints" cli:"endpoints"`
362+
}
363+
364+
tmpFile := "test_slice_yaml_override.yaml"
365+
defer os.Remove(tmpFile)
366+
367+
err := Save(tmpFile, &Config{
368+
Endpoints: []endpoint{{User: "yaml", Pass: "yaml", URL: "https://yaml"}},
369+
})
370+
assert.NoError(t, err)
371+
372+
conf := Config{}
373+
cliArgs := []string{
374+
"command",
375+
`-endpoints={"user":"cli","pass":"cli","url":"https://cli"}`,
376+
}
377+
flagSet := flag.NewFlagSet(cliArgs[0], flag.ExitOnError)
378+
err = ParseWithFlagSet(flagSet, cliArgs, &conf, WithYamlConfig(tmpFile))
379+
assert.NoError(t, err)
380+
assert.Len(t, conf.Endpoints, 1)
381+
assert.Equal(t, "cli", conf.Endpoints[0].User)
382+
})
383+
384+
t.Run("invalid env json returns error for struct slice", func(t *testing.T) {
385+
os.Clearenv()
386+
os.Setenv("CONFIGSTRUCT_ENDPOINTS", `{"user":`)
387+
388+
cliArgs := []string{"command"}
389+
flagSet := flag.NewFlagSet(cliArgs[0], flag.ExitOnError)
390+
conf := endpointConfig{}
391+
392+
err := ParseWithFlagSet(flagSet, cliArgs, &conf)
393+
assert.Error(t, err)
394+
})
395+
396+
t.Run("invalid cli json returns error for struct slice", func(t *testing.T) {
397+
os.Clearenv()
398+
cliArgs := []string{"command", `-endpoints={"user":}`}
399+
flagSet := flag.NewFlagSet(cliArgs[0], flag.ContinueOnError)
400+
conf := endpointConfig{}
401+
402+
err := ParseWithFlagSet(flagSet, cliArgs, &conf)
403+
assert.Error(t, err)
404+
})
405+
406+
t.Run("struct slice keeps defaults when no input is set", func(t *testing.T) {
407+
os.Clearenv()
408+
cliArgs := []string{"command"}
409+
flagSet := flag.NewFlagSet(cliArgs[0], flag.ExitOnError)
410+
conf := endpointConfig{
411+
Endpoints: []endpoint{{User: "default", Pass: "default", URL: "https://default"}},
412+
}
413+
414+
err := ParseWithFlagSet(flagSet, cliArgs, &conf)
415+
assert.NoError(t, err)
416+
assert.Len(t, conf.Endpoints, 1)
417+
assert.Equal(t, "default", conf.Endpoints[0].User)
418+
})
419+
250420
}
251421

252422
// Example for using `configstruct` with default values.

0 commit comments

Comments
 (0)