Skip to content

Commit

Permalink
Add .env file support, Usage functions and tests
Browse files Browse the repository at this point in the history
New features:

- `.env` config file native support
- `Usage` function
- `FUsage` function
- Tests
  • Loading branch information
ilyakaznacheev authored Jul 30, 2019
2 parents 71f6a00 + 1577598 commit 996b9e2
Show file tree
Hide file tree
Showing 7 changed files with 272 additions and 8 deletions.
6 changes: 6 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
language: go

branches:
only:
- master
- develop
- /^v\d+\.\d+(\.\d+)?(-\S*)?$/

go:
- 1.11.x
- 1.12.x
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ There are several most popular config file formats supported:
- YAML
- JSON
- TOML
- ENV

## Examples

Expand Down
49 changes: 49 additions & 0 deletions cleanenv.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package cleanenv

import (
"encoding/json"
"flag"
"fmt"
"io"
"os"
Expand All @@ -26,6 +27,7 @@ import (
"time"

"github.com/BurntSushi/toml"
"github.com/joho/godotenv"
"gopkg.in/yaml.v2"
)

Expand Down Expand Up @@ -103,6 +105,8 @@ func UpdateEnv(cfg interface{}) error {
// - json
//
// - toml
//
// - env
func parseFile(path string, cfg interface{}) error {
// open the configuration file
f, err := os.OpenFile(path, os.O_RDONLY|os.O_SYNC, 0)
Expand All @@ -119,6 +123,8 @@ func parseFile(path string, cfg interface{}) error {
err = parseJSON(f, cfg)
case ".toml":
err = parseTOML(f, cfg)
case ".env":
err = parseENV(f, cfg)
default:
return fmt.Errorf("file format '%s' doesn't supported by the parser", ext)
}
Expand All @@ -144,6 +150,21 @@ func parseTOML(r io.Reader, str interface{}) error {
return err
}

// parseENV, in fact, doesn't fill the structure with environment variable values.
// It just parses ENV file and sets all variables to the environment.
// Thus, the structure should be filled at the next steps.
func parseENV(r io.Reader, str interface{}) error {
vars, err := godotenv.Parse(r)
if err != nil {
return err
}

for env, val := range vars {
os.Setenv(env, val)
}
return nil
}

// structMeta is a strucrute metadata entity
type structMeta struct {
envList []string
Expand Down Expand Up @@ -419,3 +440,31 @@ func GetDescription(cfg interface{}, headerText *string) (string, error) {
}
return "", nil
}

// Usage returns a configuration usage help.
// Other usage instructions can be wrapped in and executed before this usage function.
// The default output is STDERR.
func Usage(cfg interface{}, headerText *string, usageFuncs ...func()) func() {
return FUsage(os.Stderr, cfg, headerText, usageFuncs...)
}

// FUsage prints configuration help into the custom output.
// Other usage instructions can be wrapped in and executed before this usage function
func FUsage(w io.Writer, cfg interface{}, headerText *string, usageFuncs ...func()) func() {
return func() {
for _, fn := range usageFuncs {
fn()
}

_ = flag.Usage

text, err := GetDescription(cfg, headerText)
if err != nil {
return
}
if len(usageFuncs) > 0 {
fmt.Fprintln(w)
}
fmt.Fprintln(w, text)
}
}
165 changes: 165 additions & 0 deletions cleanenv_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cleanenv

import (
"bytes"
"fmt"
"io/ioutil"
"os"
Expand Down Expand Up @@ -307,6 +308,82 @@ two = 2`,
}
}

func TestParseFileEnv(t *testing.T) {
type dummy struct{}

tests := []struct {
name string
rawFile string
has map[string]string
want map[string]string
wantErr bool
}{
{
name: "simple file",
has: map[string]string{
"TEST1": "aaa",
"TEST2": "bbb",
"TEST3": "ccc",
},
want: map[string]string{
"TEST1": "aaa",
"TEST2": "bbb",
"TEST3": "ccc",
},
wantErr: false,
},

{
name: "empty file",
has: map[string]string{},
want: map[string]string{},
wantErr: false,
},

{
name: "error",
rawFile: "-",
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpFile, err := ioutil.TempFile(os.TempDir(), "*.env")
if err != nil {
t.Fatal("cannot create temporary file:", err)
}
defer os.Remove(tmpFile.Name())

var file string
if tt.rawFile == "" {
for key, val := range tt.has {
file += fmt.Sprintf("%s=%s\n", key, val)
}
} else {
file = tt.rawFile
}

text := []byte(file)
if _, err = tmpFile.Write(text); err != nil {
t.Fatal("failed to write to temporary file:", err)
}

var cfg dummy
if err = parseFile(tmpFile.Name(), &cfg); (err != nil) != tt.wantErr {
t.Errorf("wrong error behavior %v, wantErr %v", err, tt.wantErr)
}
for key, val := range tt.has {
if envVal := os.Getenv(key); err == nil && val != envVal {
t.Errorf("wrong value %s of var %s, want %s", envVal, key, val)
}
}

os.Clearenv()
})
}
}

func TestGetDescription(t *testing.T) {
type testSingleEnv struct {
One int `env:"ONE" env-description:"one"`
Expand Down Expand Up @@ -438,3 +515,91 @@ func TestGetDescription(t *testing.T) {
})
}
}

func TestFUsage(t *testing.T) {
type testSingleEnv struct {
One int `env:"ONE" env-description:"one"`
Two int `env:"TWO" env-description:"two"`
Three int `env:"THREE" env-description:"three"`
}

customHeader := "test header:"

tests := []struct {
name string
headerText *string
usageTexts []string
want string
}{
{
name: "no custom usage",
headerText: nil,
usageTexts: nil,
want: "Environment variables:" +
"\n ONE int\n \tone" +
"\n TWO int\n \ttwo" +
"\n THREE int\n \tthree\n",
},

{
name: "custom header",
headerText: &customHeader,
usageTexts: nil,
want: "test header:" +
"\n ONE int\n \tone" +
"\n TWO int\n \ttwo" +
"\n THREE int\n \tthree\n",
},

{
name: "custom usages",
headerText: nil,
usageTexts: []string{
"test1",
"test2",
"test3",
},
want: "test1\ntest2\ntest3\n" +
"\nEnvironment variables:" +
"\n ONE int\n \tone" +
"\n TWO int\n \ttwo" +
"\n THREE int\n \tthree\n",
},

{
name: "custom usages and header",
headerText: &customHeader,
usageTexts: []string{
"test1",
"test2",
"test3",
},
want: "test1\ntest2\ntest3\n" +
"\ntest header:" +
"\n ONE int\n \tone" +
"\n TWO int\n \ttwo" +
"\n THREE int\n \tthree\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := &bytes.Buffer{}
uFuncs := make([]func(), 0, len(tt.usageTexts))
for _, text := range tt.usageTexts {
uFuncs = append(uFuncs, func(a string) func() {
return func() {
fmt.Fprintln(w, a)
}
}(text))
}
var cfg testSingleEnv
FUsage(w, &cfg, tt.headerText, uFuncs...)()
gotRaw, _ := ioutil.ReadAll(w)
got := string(gotRaw)

if got != tt.want {
t.Errorf("wrong output %v, want %v", got, tt.want)
}
})
}
}
56 changes: 48 additions & 8 deletions example_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cleanenv_test

import (
"flag"
"fmt"
"os"

Expand Down Expand Up @@ -57,8 +58,8 @@ func ExampleGetDescription_defaults() {
// third parameter (default "test")
}

// ExampleGetDescription_variable_list builds a description text from structure tags with description of alternative variables
func ExampleGetDescription_variable_list() {
// ExampleGetDescription_variableList builds a description text from structure tags with description of alternative variables
func ExampleGetDescription_variableList() {
type config struct {
FirstVar int64 `env:"ONE,TWO,THREE" env-description:"first found parameter"`
}
Expand All @@ -80,8 +81,8 @@ func ExampleGetDescription_variable_list() {
// first found parameter
}

// ExampleGetDescription_custom_header_text builds a description text from structure tags with custom header string
func ExampleGetDescription_custom_header_text() {
// ExampleGetDescription_customHeaderText builds a description text from structure tags with custom header string
func ExampleGetDescription_customHeaderText() {
type config struct {
One int64 `env:"ONE" env-description:"first parameter"`
Two float64 `env:"TWO" env-description:"second parameter"`
Expand Down Expand Up @@ -177,8 +178,8 @@ func (f MyField) String() string {
return string(f)
}

// ExampleSetter_SetValue uses type with a custom setter to parse environment variable data
func ExampleSetter_SetValue() {
// Example_setter uses type with a custom setter to parse environment variable data
func Example_setter() {
type config struct {
Default string `env:"ONE"`
Custom MyField `env:"TWO"`
Expand All @@ -205,8 +206,8 @@ func (c *ConfigUpdate) Update() error {
return nil
}

// ExampleUpdater_Update uses a type with a custom updater
func ExampleUpdater_Update() {
// Example_updater uses a type with a custom updater
func Example_updater() {
var cfg ConfigUpdate

os.Setenv("DEFAULT", "default")
Expand All @@ -215,3 +216,42 @@ func ExampleUpdater_Update() {
fmt.Printf("%+v\n", cfg)
//Output: {Default:default Custom:custom}
}

func ExampleUsage() {
os.Stderr = os.Stdout //replace STDERR with STDOUT for test

type config struct {
One int64 `env:"ONE" env-description:"first parameter"`
Two float64 `env:"TWO" env-description:"second parameter"`
Three string `env:"THREE" env-description:"third parameter"`
}

// setup flags
fset := flag.NewFlagSet("Example", flag.ContinueOnError)

fset.String("p", "8080", "service port")
fset.String("h", "localhost", "service host")

var cfg config
customHeader := "My sweet variables:"

// get config usage with wrapped flag usage and custom header string
u := cleanenv.Usage(&cfg, &customHeader, fset.Usage)

// print usage to STDERR
u()

//Output: Usage of Example:
// -h string
// service host (default "localhost")
// -p string
// service port (default "8080")
//
// My sweet variables:
// ONE int64
// first parameter
// TWO float64
// second parameter
// THREE string
// third parameter
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ module github.com/ilyakaznacheev/cleanenv

require (
github.com/BurntSushi/toml v0.3.1
github.com/joho/godotenv v1.3.0
gopkg.in/yaml.v2 v2.2.2
)
Loading

0 comments on commit 996b9e2

Please sign in to comment.