Skip to content

Commit b2c178d

Browse files
committed
require fields to be present if not tagged with optional
1 parent 685da7c commit b2c178d

File tree

4 files changed

+128
-34
lines changed

4 files changed

+128
-34
lines changed

README.md

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,15 @@ package main
3434

3535
import (
3636
"fmt"
37-
"github.com/qiangxue/go-env"
3837
"os"
38+
39+
"github.com/qiangxue/go-env"
3940
)
4041

4142
type Config struct {
42-
Host string
43-
Port int
43+
Host string
44+
Port int
45+
Password string `env:",optional"`
4446
}
4547

4648
func main() {
@@ -53,12 +55,18 @@ func main() {
5355
}
5456
fmt.Println(cfg.Host)
5557
fmt.Println(cfg.Port)
58+
fmt.Println(cfg.Password)
5659
// Output:
5760
// 127.0.0.1
5861
// 8080
62+
//
5963
}
6064
```
6165

66+
In the above code, the `Password` field is tagged as `optional` whereas the other fields are untagged.
67+
If a field is not tagged as `optional`, it is a required field, so `env.Load()` will return an error if
68+
the corresponding environment variable is missing.
69+
6270
### Environment Variable Names
6371

6472
When go-env populates a struct from environment variables, it uses the following rules to match
@@ -78,9 +86,10 @@ package main
7886

7987
import (
8088
"fmt"
81-
"github.com/qiangxue/go-env"
8289
"log"
8390
"os"
91+
92+
"github.com/qiangxue/go-env"
8493
)
8594

8695
type Config struct {

env.go

Lines changed: 61 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ type (
3636
// Set sets the object with a string value.
3737
Set(value string) error
3838
}
39+
40+
options struct {
41+
optional bool
42+
secret bool
43+
}
3944
)
4045

4146
var (
@@ -123,7 +128,7 @@ func (l *Loader) Load(structPtr interface{}) error {
123128
continue
124129
}
125130

126-
name, secret := getName(ft.Tag.Get(TagName), ft.Name)
131+
name, options := getName(ft.Tag.Get(TagName), ft.Name)
127132
if name == "-" {
128133
continue
129134
}
@@ -133,7 +138,7 @@ func (l *Loader) Load(structPtr interface{}) error {
133138
if value, ok := l.lookup(name); ok {
134139
logValue := value
135140
if l.log != nil {
136-
if secret {
141+
if options.secret {
137142
l.log("set %v with $%v=\"***\"", ft.Name, name)
138143
} else {
139144
l.log("set %v with $%v=\"%v\"", ft.Name, name, logValue)
@@ -142,6 +147,8 @@ func (l *Loader) Load(structPtr interface{}) error {
142147
if err := setValue(f, value); err != nil {
143148
return fmt.Errorf("error reading \"%v\": %v", ft.Name, err)
144149
}
150+
} else if !options.optional {
151+
return fmt.Errorf("missing required environment variable \"%v\"", name)
145152
}
146153
}
147154
return nil
@@ -159,18 +166,61 @@ func indirect(v reflect.Value) reflect.Value {
159166
return v
160167
}
161168

162-
// getName generates the environment variable name from a struct field tag and the field name.
163-
func getName(tag string, field string) (string, bool) {
164-
name := strings.TrimSuffix(tag, ",secret")
165-
nameLen := len(name)
169+
// getName extracts the environment variable name and options from the given struct field tag or if unspecified,
170+
// generates the environment variable name from the given field name.
171+
func getName(tag string, field string) (string, options) {
172+
name, options := getOptions(tag)
173+
if name == "" {
174+
name = camelCaseToUpperSnakeCase(field)
175+
}
176+
return name, options
177+
}
166178

167-
// If the `,secret` suffix was found, it would have been trimmed, so the length should be different.
168-
secret := nameLen < len(tag)
179+
// getOptions extracts the environment variable name and options from the given struct field tag.
180+
func getOptions(tag string) (string, options) {
181+
var options options
169182

170-
if nameLen == 0 {
171-
name = camelCaseToUpperSnakeCase(field)
183+
optionNamesAndPointers := []struct {
184+
name string
185+
pointer *bool
186+
}{
187+
{"optional", &options.optional},
188+
{"secret", &options.secret},
189+
}
190+
191+
trimmedTag := tag
192+
// We do not know the order that the options will be specified in, so we need to do extra checking.
193+
// `O(n^2)` but `n` is really small.
194+
outerLoop:
195+
for {
196+
for i, optionNameAndPointer := range optionNamesAndPointers {
197+
var option bool
198+
if trimmedTag, option = getOption(trimmedTag, optionNameAndPointer.name); option {
199+
*optionNameAndPointer.pointer = option
200+
201+
// We found the option, so remove it from the slice and retry the rest of the options.
202+
optionNamesAndPointers[i] = optionNamesAndPointers[len(optionNamesAndPointers)-1]
203+
optionNamesAndPointers = optionNamesAndPointers[:len(optionNamesAndPointers)-1]
204+
continue outerLoop
205+
}
206+
}
207+
208+
// We checked for all the options and none were specified, so we are done.
209+
break
172210
}
173-
return name, secret
211+
212+
return trimmedTag, options
213+
}
214+
215+
// getOption checks whether the given struct field tag contains the suffix for the given option and
216+
// returns the tag without the suffix.
217+
func getOption(tag string, optionName string) (string, bool) {
218+
trimmedTag := strings.TrimSuffix(tag, ","+optionName)
219+
220+
// If the suffix for the option was found, it would have been trimmed, so the length should be different.
221+
option := len(trimmedTag) < len(tag)
222+
223+
return trimmedTag, option
174224
}
175225

176226
// camelCaseToUpperSnakeCase converts a name from camelCase format into UPPER_SNAKE_CASE format.

env_test.go

Lines changed: 49 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -150,25 +150,30 @@ func Test_camelCaseToUpperSnakeCase(t *testing.T) {
150150

151151
func Test_getName(t *testing.T) {
152152
tests := []struct {
153-
tag string
154-
tg string
155-
field string
156-
name string
157-
secret bool
153+
tag string
154+
tg string
155+
field string
156+
name string
157+
options options
158158
}{
159-
{"t1", "", "Name", "NAME", false},
160-
{"t2", "", "MyName", "MY_NAME", false},
161-
{"t3", "NaME", "Name", "NaME", false},
162-
{"t4", "NaME,secret", "Name", "NaME", true},
163-
{"t5", ",secret", "Name", "NAME", true},
164-
{"t6", "NameWith,Comma", "Name", "NameWith,Comma", false},
165-
{"t7", "NameWith,Comma,secret", "Name", "NameWith,Comma", true},
159+
{"t1", "", "Name", "NAME", options{optional: false, secret: false}},
160+
{"t2", "", "MyName", "MY_NAME", options{optional: false, secret: false}},
161+
{"t3", "NaME", "Name", "NaME", options{optional: false, secret: false}},
162+
{"t4", "NaME,optional", "Name", "NaME", options{optional: true, secret: false}},
163+
{"t5", "NaME,secret", "Name", "NaME", options{optional: false, secret: true}},
164+
{"t6", ",optional", "Name", "NAME", options{optional: true, secret: false}},
165+
{"t7", ",secret", "Name", "NAME", options{optional: false, secret: true}},
166+
{"t8", ",optional,secret", "Name", "NAME", options{optional: true, secret: true}},
167+
{"t9", ",secret,optional", "Name", "NAME", options{optional: true, secret: true}},
168+
{"t10", "NameWith,Comma", "Name", "NameWith,Comma", options{optional: false, secret: false}},
169+
{"t11", "NameWith,Comma,optional", "Name", "NameWith,Comma", options{optional: true, secret: false}},
170+
{"t12", "NameWith,Comma,optional,secret", "Name", "NameWith,Comma", options{optional: true, secret: true}},
166171
}
167172

168173
for _, test := range tests {
169-
name, secret := getName(test.tg, test.field)
174+
name, options := getName(test.tg, test.field)
170175
assert.Equal(t, test.name, name, test.tag)
171-
assert.Equal(t, test.secret, secret, test.tag)
176+
assert.Equal(t, test.options, options, test.tag)
172177
}
173178
}
174179

@@ -207,7 +212,18 @@ func mockLookup2(name string) (string, bool) {
207212

208213
func mockLookup3(name string) (string, bool) {
209214
data := map[string]string{
210-
"PORT": "a8080",
215+
"HOST": "localhost",
216+
"PORT": "a8080", // invalid `int`
217+
"URL": "http://example.com",
218+
}
219+
value, ok := data[name]
220+
return value, ok
221+
}
222+
223+
func mockLookup4(name string) (string, bool) {
224+
data := map[string]string{
225+
"PORT": "8080",
226+
"URL": "http://example.com",
211227
}
212228
value, ok := data[name]
213229
return value, ok
@@ -235,6 +251,12 @@ type Config3 struct {
235251
Embedded
236252
}
237253

254+
type Config4 struct {
255+
Host string `env:",optional"`
256+
Port int
257+
Embedded
258+
}
259+
238260
func TestLoader_Load(t *testing.T) {
239261
l := NewWithLookup("", mockLookup, nil)
240262

@@ -267,12 +289,22 @@ func TestLoader_Load(t *testing.T) {
267289
var cfg3 Config1
268290
l = NewWithLookup("", mockLookup3, nil)
269291
err = l.Load(&cfg3)
270-
assert.NotNil(t, err)
292+
assert.EqualError(t, err, "error reading \"Port\": strconv.ParseInt: parsing \"a8080\": invalid syntax")
271293

272294
var cfg4 Config3
273295
l = NewWithLookup("", mockLookup3, nil)
274296
err = l.Load(&cfg4)
275-
assert.NotNil(t, err)
297+
assert.EqualError(t, err, "error reading \"Port\": strconv.ParseInt: parsing \"a8080\": invalid syntax")
298+
299+
var cfg5 Config1
300+
l = NewWithLookup("T_", mockLookup4, nil)
301+
err = l.Load(&cfg5)
302+
assert.EqualError(t, err, "missing required environment variable \"T_HOST\"")
303+
304+
var cfg6 Config4
305+
l = NewWithLookup("", mockLookup4, nil)
306+
err = l.Load(&cfg6)
307+
assert.Nil(t, err)
276308
}
277309

278310
func TestNew(t *testing.T) {

example_test.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,16 @@ package env_test
22

33
import (
44
"fmt"
5-
"github.com/qiangxue/go-env"
65
"log"
76
"os"
7+
8+
"github.com/qiangxue/go-env"
89
)
910

1011
type Config struct {
1112
Host string
1213
Port int
13-
Password string `env:",secret"`
14+
Password string `env:",optional,secret"`
1415
}
1516

1617
func Example_one() {
@@ -23,9 +24,11 @@ func Example_one() {
2324
}
2425
fmt.Println(cfg.Host)
2526
fmt.Println(cfg.Port)
27+
fmt.Println(cfg.Password)
2628
// Output:
2729
// 127.0.0.1
2830
// 8080
31+
//
2932
}
3033

3134
func Example_two() {

0 commit comments

Comments
 (0)