Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,15 @@ package main

import (
"fmt"
"github.com/qiangxue/go-env"
"os"

"github.com/qiangxue/go-env"
)

type Config struct {
Host string
Port int
Host string
Port int
Password string `env:",optional"`
}

func main() {
Expand All @@ -53,12 +55,18 @@ func main() {
}
fmt.Println(cfg.Host)
fmt.Println(cfg.Port)
fmt.Println(cfg.Password)
// Output:
// 127.0.0.1
// 8080
//
}
```

In the above code, the `Password` field is tagged as `optional` whereas the other fields are untagged.
If a field is not tagged as `optional`, it is a required field, so `env.Load()` will return an error if
the corresponding environment variable is missing.

### Environment Variable Names

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

import (
"fmt"
"github.com/qiangxue/go-env"
"log"
"os"

"github.com/qiangxue/go-env"
)

type Config struct {
Expand Down
72 changes: 61 additions & 11 deletions env.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ type (
// Set sets the object with a string value.
Set(value string) error
}

options struct {
optional bool
secret bool
}
)

var (
Expand Down Expand Up @@ -123,7 +128,7 @@ func (l *Loader) Load(structPtr interface{}) error {
continue
}

name, secret := getName(ft.Tag.Get(TagName), ft.Name)
name, options := getName(ft.Tag.Get(TagName), ft.Name)
if name == "-" {
continue
}
Expand All @@ -133,7 +138,7 @@ func (l *Loader) Load(structPtr interface{}) error {
if value, ok := l.lookup(name); ok {
logValue := value
if l.log != nil {
if secret {
if options.secret {
l.log("set %v with $%v=\"***\"", ft.Name, name)
} else {
l.log("set %v with $%v=\"%v\"", ft.Name, name, logValue)
Expand All @@ -142,6 +147,8 @@ func (l *Loader) Load(structPtr interface{}) error {
if err := setValue(f, value); err != nil {
return fmt.Errorf("error reading \"%v\": %v", ft.Name, err)
}
} else if !options.optional {
return fmt.Errorf("missing required environment variable \"%v\"", name)
}
}
return nil
Expand All @@ -159,18 +166,61 @@ func indirect(v reflect.Value) reflect.Value {
return v
}

// getName generates the environment variable name from a struct field tag and the field name.
func getName(tag string, field string) (string, bool) {
name := strings.TrimSuffix(tag, ",secret")
nameLen := len(name)
// getName extracts the environment variable name and options from the given struct field tag or if unspecified,
// generates the environment variable name from the given field name.
func getName(tag string, field string) (string, options) {
name, options := getOptions(tag)
if name == "" {
name = camelCaseToUpperSnakeCase(field)
}
return name, options
}

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

if nameLen == 0 {
name = camelCaseToUpperSnakeCase(field)
optionNamesAndPointers := []struct {
name string
pointer *bool
}{
{"optional", &options.optional},
{"secret", &options.secret},
}

trimmedTag := tag
// We do not know the order that the options will be specified in, so we need to do extra checking.
// `O(n^2)` but `n` is really small.
outerLoop:
for {
for i, optionNameAndPointer := range optionNamesAndPointers {
var option bool
if trimmedTag, option = getOption(trimmedTag, optionNameAndPointer.name); option {
*optionNameAndPointer.pointer = option

// We found the option, so remove it from the slice and retry the rest of the options.
optionNamesAndPointers[i] = optionNamesAndPointers[len(optionNamesAndPointers)-1]
optionNamesAndPointers = optionNamesAndPointers[:len(optionNamesAndPointers)-1]
continue outerLoop
}
}

// We checked for all the options and none were specified, so we are done.
break
}
return name, secret

return trimmedTag, options
}

// getOption checks whether the given struct field tag contains the suffix for the given option and
// returns the tag without the suffix.
func getOption(tag string, optionName string) (string, bool) {
trimmedTag := strings.TrimSuffix(tag, ","+optionName)

// If the suffix for the option was found, it would have been trimmed, so the length should be different.
option := len(trimmedTag) < len(tag)

return trimmedTag, option
}

// camelCaseToUpperSnakeCase converts a name from camelCase format into UPPER_SNAKE_CASE format.
Expand Down
66 changes: 49 additions & 17 deletions env_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,25 +150,30 @@ func Test_camelCaseToUpperSnakeCase(t *testing.T) {

func Test_getName(t *testing.T) {
tests := []struct {
tag string
tg string
field string
name string
secret bool
tag string
tg string
field string
name string
options options
}{
{"t1", "", "Name", "NAME", false},
{"t2", "", "MyName", "MY_NAME", false},
{"t3", "NaME", "Name", "NaME", false},
{"t4", "NaME,secret", "Name", "NaME", true},
{"t5", ",secret", "Name", "NAME", true},
{"t6", "NameWith,Comma", "Name", "NameWith,Comma", false},
{"t7", "NameWith,Comma,secret", "Name", "NameWith,Comma", true},
{"t1", "", "Name", "NAME", options{optional: false, secret: false}},
{"t2", "", "MyName", "MY_NAME", options{optional: false, secret: false}},
{"t3", "NaME", "Name", "NaME", options{optional: false, secret: false}},
{"t4", "NaME,optional", "Name", "NaME", options{optional: true, secret: false}},
{"t5", "NaME,secret", "Name", "NaME", options{optional: false, secret: true}},
{"t6", ",optional", "Name", "NAME", options{optional: true, secret: false}},
{"t7", ",secret", "Name", "NAME", options{optional: false, secret: true}},
{"t8", ",optional,secret", "Name", "NAME", options{optional: true, secret: true}},
{"t9", ",secret,optional", "Name", "NAME", options{optional: true, secret: true}},
{"t10", "NameWith,Comma", "Name", "NameWith,Comma", options{optional: false, secret: false}},
{"t11", "NameWith,Comma,optional", "Name", "NameWith,Comma", options{optional: true, secret: false}},
{"t12", "NameWith,Comma,optional,secret", "Name", "NameWith,Comma", options{optional: true, secret: true}},
}

for _, test := range tests {
name, secret := getName(test.tg, test.field)
name, options := getName(test.tg, test.field)
assert.Equal(t, test.name, name, test.tag)
assert.Equal(t, test.secret, secret, test.tag)
assert.Equal(t, test.options, options, test.tag)
}
}

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

func mockLookup3(name string) (string, bool) {
data := map[string]string{
"PORT": "a8080",
"HOST": "localhost",
"PORT": "a8080", // invalid `int`
"URL": "http://example.com",
}
value, ok := data[name]
return value, ok
}

func mockLookup4(name string) (string, bool) {
data := map[string]string{
"PORT": "8080",
"URL": "http://example.com",
}
value, ok := data[name]
return value, ok
Expand Down Expand Up @@ -235,6 +251,12 @@ type Config3 struct {
Embedded
}

type Config4 struct {
Host string `env:",optional"`
Port int
Embedded
}

func TestLoader_Load(t *testing.T) {
l := NewWithLookup("", mockLookup, nil)

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

var cfg4 Config3
l = NewWithLookup("", mockLookup3, nil)
err = l.Load(&cfg4)
assert.NotNil(t, err)
assert.EqualError(t, err, "error reading \"Port\": strconv.ParseInt: parsing \"a8080\": invalid syntax")

var cfg5 Config1
l = NewWithLookup("T_", mockLookup4, nil)
err = l.Load(&cfg5)
assert.EqualError(t, err, "missing required environment variable \"T_HOST\"")

var cfg6 Config4
l = NewWithLookup("", mockLookup4, nil)
err = l.Load(&cfg6)
assert.Nil(t, err)
}

func TestNew(t *testing.T) {
Expand Down
7 changes: 5 additions & 2 deletions example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,16 @@ package env_test

import (
"fmt"
"github.com/qiangxue/go-env"
"log"
"os"

"github.com/qiangxue/go-env"
)

type Config struct {
Host string
Port int
Password string `env:",secret"`
Password string `env:",optional,secret"`
}

func Example_one() {
Expand All @@ -23,9 +24,11 @@ func Example_one() {
}
fmt.Println(cfg.Host)
fmt.Println(cfg.Port)
fmt.Println(cfg.Password)
// Output:
// 127.0.0.1
// 8080
//
}

func Example_two() {
Expand Down