diff --git a/cmd/ddl.go b/cmd/ddl.go index c2898570..06202071 100644 --- a/cmd/ddl.go +++ b/cmd/ddl.go @@ -1,7 +1,7 @@ package cmd import ( - "github.com/kelseyhightower/envconfig" + "github.com/unionj-cloud/go-doudou/v2/toolkit/envconfig" "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/unionj-cloud/go-doudou/v2/cmd/internal/ddl" diff --git a/cmd/internal/ddl/table/ddl.go b/cmd/internal/ddl/table/ddl.go index 053d7226..372a17c4 100644 --- a/cmd/internal/ddl/table/ddl.go +++ b/cmd/internal/ddl/table/ddl.go @@ -6,7 +6,7 @@ import ( mapset "github.com/deckarep/golang-set" "github.com/iancoleman/strcase" "github.com/jmoiron/sqlx" - "github.com/kelseyhightower/envconfig" + "github.com/unionj-cloud/go-doudou/v2/toolkit/envconfig" "github.com/pkg/errors" "github.com/rs/zerolog" "github.com/testcontainers/testcontainers-go" diff --git a/cmd/internal/svc/codegen/config.go b/cmd/internal/svc/codegen/config.go index 5eafcf60..e9d1f4af 100644 --- a/cmd/internal/svc/codegen/config.go +++ b/cmd/internal/svc/codegen/config.go @@ -14,9 +14,9 @@ import ( var configTmpl = templates.EditableHeaderTmpl + `package config import ( - "github.com/kelseyhightower/envconfig" + "github.com/unionj-cloud/go-doudou/v2/toolkit/envconfig" "github.com/unionj-cloud/go-doudou/v2/framework/config" - "github.com/unionj-cloud/go-doudou/v2/toolkit/errorx" + "github.com/unionj-cloud/go-doudou/v2/toolkit/zlogger" ) var G_Config *Config @@ -32,7 +32,7 @@ func init() { var conf Config err := envconfig.Process("{{.ServiceName}}", &conf) if err != nil { - errorx.Panic("Error processing environment variables") + zlogger.Panic().Msgf("Error processing environment variables: %v", err) } G_Config = &conf } diff --git a/cmd/internal/svc/codegen/init.go b/cmd/internal/svc/codegen/init.go index b8c6d78a..aa11dfad 100644 --- a/cmd/internal/svc/codegen/init.go +++ b/cmd/internal/svc/codegen/init.go @@ -116,7 +116,6 @@ require ( github.com/gorilla/handlers v1.5.1 github.com/iancoleman/strcase v0.1.3 github.com/jmoiron/sqlx v1.3.1 - github.com/kelseyhightower/envconfig v1.4.0 github.com/opentracing-contrib/go-stdlib v1.0.0 github.com/opentracing/opentracing-go v1.2.0 github.com/pkg/errors v0.9.1 diff --git a/cmd/internal/svc/codegen/testdata/config/config.go b/cmd/internal/svc/codegen/testdata/config/config.go index b9bb4494..6a2050d1 100644 --- a/cmd/internal/svc/codegen/testdata/config/config.go +++ b/cmd/internal/svc/codegen/testdata/config/config.go @@ -1,7 +1,7 @@ package config import ( - "github.com/kelseyhightower/envconfig" + "github.com/unionj-cloud/go-doudou/v2/toolkit/envconfig" "github.com/sirupsen/logrus" ) diff --git a/cmd/testdata/testsvc/config/config.go b/cmd/testdata/testsvc/config/config.go index b9bb4494..6a2050d1 100644 --- a/cmd/testdata/testsvc/config/config.go +++ b/cmd/testdata/testsvc/config/config.go @@ -1,7 +1,7 @@ package config import ( - "github.com/kelseyhightower/envconfig" + "github.com/unionj-cloud/go-doudou/v2/toolkit/envconfig" "github.com/sirupsen/logrus" ) diff --git a/framework/config/config.go b/framework/config/config.go index 09f17f38..89c88ce1 100644 --- a/framework/config/config.go +++ b/framework/config/config.go @@ -18,10 +18,10 @@ import ( "github.com/wubin1989/nacos-sdk-go/v2/vo" _ "go.uber.org/automaxprocs" - "github.com/kelseyhightower/envconfig" "github.com/unionj-cloud/go-doudou/v2/framework/configmgr" "github.com/unionj-cloud/go-doudou/v2/toolkit/cast" "github.com/unionj-cloud/go-doudou/v2/toolkit/dotenv" + "github.com/unionj-cloud/go-doudou/v2/toolkit/envconfig" "github.com/unionj-cloud/go-doudou/v2/toolkit/stringutils" "github.com/unionj-cloud/go-doudou/v2/toolkit/yaml" "github.com/unionj-cloud/go-doudou/v2/toolkit/zlogger" @@ -97,7 +97,7 @@ func init() { LoadConfigFromRemote() err := envconfig.Process("gdd", GddConfig) if err != nil { - zlogger.Panic().Msgf("Error processing environment variables: %s", err.Error()) + zlogger.Panic().Msgf("Error processing environment variables: %v", err) } zl, _ := zerolog.ParseLevel(GddLogLevel.LoadOrDefault(DefaultGddLogLevel)) opts := []zlogger.LoggerConfigOption{ @@ -534,19 +534,19 @@ type Config struct { } type gddConfig struct { - Banner bool - BannerText string `default:"go-doudou" split_words:"true"` - RouteRootPath string `default:"/" split_words:"true"` - WriteTimeout time.Duration `default:"15s" split_words:"true"` - ReadTimeout time.Duration `default:"15s" split_words:"true"` - IdleTimeout time.Duration `default:"60s" split_words:"true"` - GraceTimeout time.Duration `default:"15s" split_words:"true"` - Port string `default:"6060"` - Host string - EnableResponseGzip bool `default:"true" split_words:"true"` - LogReqEnable bool `default:"false" split_words:"true"` - ManageEnable bool `default:"true" split_words:"true"` - Grpc struct { + Banner bool + BannerText string `default:"go-doudou" split_words:"true"` + RouteRootPath string `default:"/" split_words:"true"` + WriteTimeout time.Duration `default:"15s" split_words:"true"` + ReadTimeout time.Duration `default:"15s" split_words:"true"` + IdleTimeout time.Duration `default:"60s" split_words:"true"` + GraceTimeout time.Duration `default:"15s" split_words:"true"` + Port string `default:"6060"` + Host string + EnableResponseGzip bool `default:"true" split_words:"true"` + LogReqEnable bool `default:"false" split_words:"true"` + ManageEnable bool `default:"true" split_words:"true"` + Grpc struct { Port string `default:"50051"` } Config diff --git a/toolkit/envconfig/LICENSE b/toolkit/envconfig/LICENSE new file mode 100644 index 00000000..4bfa7a84 --- /dev/null +++ b/toolkit/envconfig/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2013 Kelsey Hightower + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/toolkit/envconfig/README.md b/toolkit/envconfig/README.md new file mode 100644 index 00000000..3446d2fe --- /dev/null +++ b/toolkit/envconfig/README.md @@ -0,0 +1,231 @@ +# envconfig + +[![Build Status](https://travis-ci.org/kelseyhightower/envconfig.svg)](https://travis-ci.org/kelseyhightower/envconfig) + +```Go +import "github.com/kelseyhightower/envconfig" +``` + +## Documentation + +See [godoc](http://godoc.org/github.com/kelseyhightower/envconfig) + +## Usage + +Set some environment variables: + +```Bash +export MYAPP_DEBUG=false +export MYAPP_PORT=8080 +export MYAPP_USER=Kelsey +export MYAPP_RATE="0.5" +export MYAPP_TIMEOUT="3m" +export MYAPP_USERS="rob,ken,robert" +export MYAPP_COLORCODES="red:1,green:2,blue:3" +``` + +Write some code: + +```Go +package main + +import ( + "fmt" + "log" + "time" + + "github.com/kelseyhightower/envconfig" +) + +type Specification struct { + Debug bool + Port int + User string + Users []string + Rate float32 + Timeout time.Duration + ColorCodes map[string]int +} + +func main() { + var s Specification + err := envconfig.Process("myapp", &s) + if err != nil { + log.Fatal(err.Error()) + } + format := "Debug: %v\nPort: %d\nUser: %s\nRate: %f\nTimeout: %s\n" + _, err = fmt.Printf(format, s.Debug, s.Port, s.User, s.Rate, s.Timeout) + if err != nil { + log.Fatal(err.Error()) + } + + fmt.Println("Users:") + for _, u := range s.Users { + fmt.Printf(" %s\n", u) + } + + fmt.Println("Color codes:") + for k, v := range s.ColorCodes { + fmt.Printf(" %s: %d\n", k, v) + } +} +``` + +Results: + +```Bash +Debug: false +Port: 8080 +User: Kelsey +Rate: 0.500000 +Timeout: 3m0s +Users: + rob + ken + robert +Color codes: + red: 1 + green: 2 + blue: 3 +``` + +## Struct Tag Support + +Envconfig supports the use of struct tags to specify alternate, default, and required +environment variables. + +For example, consider the following struct: + +```Go +type Specification struct { + ManualOverride1 string `envconfig:"manual_override_1"` + DefaultVar string `default:"foobar"` + RequiredVar string `required:"true"` + IgnoredVar string `ignored:"true"` + AutoSplitVar string `split_words:"true"` + RequiredAndAutoSplitVar string `required:"true" split_words:"true"` +} +``` + +Envconfig has automatic support for CamelCased struct elements when the +`split_words:"true"` tag is supplied. Without this tag, `AutoSplitVar` above +would look for an environment variable called `MYAPP_AUTOSPLITVAR`. With the +setting applied it will look for `MYAPP_AUTO_SPLIT_VAR`. Note that numbers +will get globbed into the previous word. If the setting does not do the +right thing, you may use a manual override. + +Envconfig will process value for `ManualOverride1` by populating it with the +value for `MYAPP_MANUAL_OVERRIDE_1`. Without this struct tag, it would have +instead looked up `MYAPP_MANUALOVERRIDE1`. With the `split_words:"true"` tag +it would have looked up `MYAPP_MANUAL_OVERRIDE1`. + +```Bash +export MYAPP_MANUAL_OVERRIDE_1="this will be the value" + +# export MYAPP_MANUALOVERRIDE1="and this will not" +``` + +If envconfig can't find an environment variable value for `MYAPP_DEFAULTVAR`, +it will populate it with "foobar" as a default value. + +If envconfig can't find an environment variable value for `MYAPP_REQUIREDVAR`, +it will return an error when asked to process the struct. If +`MYAPP_REQUIREDVAR` is present but empty, envconfig will not return an error. + +If envconfig can't find an environment variable in the form `PREFIX_MYVAR`, and there +is a struct tag defined, it will try to populate your variable with an environment +variable that directly matches the envconfig tag in your struct definition: + +```shell +export SERVICE_HOST=127.0.0.1 +export MYAPP_DEBUG=true +``` +```Go +type Specification struct { + ServiceHost string `envconfig:"SERVICE_HOST"` + Debug bool +} +``` + +Envconfig won't process a field with the "ignored" tag set to "true", even if a corresponding +environment variable is set. + +## Supported Struct Field Types + +envconfig supports these struct field types: + + * string + * int8, int16, int32, int64 + * bool + * float32, float64 + * slices of any supported type + * maps (keys and values of any supported type) + * [encoding.TextUnmarshaler](https://golang.org/pkg/encoding/#TextUnmarshaler) + * [encoding.BinaryUnmarshaler](https://golang.org/pkg/encoding/#BinaryUnmarshaler) + * [time.Duration](https://golang.org/pkg/time/#Duration) + +Embedded structs using these fields are also supported. + +## Custom Decoders + +Any field whose type (or pointer-to-type) implements `envconfig.Decoder` can +control its own deserialization: + +```Bash +export DNS_SERVER=8.8.8.8 +``` + +```Go +type IPDecoder net.IP + +func (ipd *IPDecoder) Decode(value string) error { + *ipd = IPDecoder(net.ParseIP(value)) + return nil +} + +type DNSConfig struct { + Address IPDecoder `envconfig:"DNS_SERVER"` +} +``` + +Example for decoding the environment variables into map[string][]structName type + +```Bash +export SMS_PROVIDER_WITH_WEIGHT= `IND=[{"name":"SMSProvider1","weight":70},{"name":"SMSProvider2","weight":30}];US=[{"name":"SMSProvider1","weight":100}]` +``` + +```GO +type providerDetails struct { + Name string + Weight int +} + +type SMSProviderDecoder map[string][]providerDetails + +func (sd *SMSProviderDecoder) Decode(value string) error { + smsProvider := map[string][]providerDetails{} + pairs := strings.Split(value, ";") + for _, pair := range pairs { + providerdata := []providerDetails{} + kvpair := strings.Split(pair, "=") + if len(kvpair) != 2 { + return fmt.Errorf("invalid map item: %q", pair) + } + err := json.Unmarshal([]byte(kvpair[1]), &providerdata) + if err != nil { + return fmt.Errorf("invalid map json: %w", err) + } + smsProvider[kvpair[0]] = providerdata + + } + *sd = SMSProviderDecoder(smsProvider) + return nil +} + +type SMSProviderConfig struct { + ProviderWithWeight SMSProviderDecoder `envconfig:"SMS_PROVIDER_WITH_WEIGHT"` +} +``` + +Also, envconfig will use a `Set(string) error` method like from the +[flag.Value](https://godoc.org/flag#Value) interface if implemented. diff --git a/toolkit/envconfig/doc.go b/toolkit/envconfig/doc.go new file mode 100644 index 00000000..f28561cd --- /dev/null +++ b/toolkit/envconfig/doc.go @@ -0,0 +1,8 @@ +// Copyright (c) 2013 Kelsey Hightower. All rights reserved. +// Use of this source code is governed by the MIT License that can be found in +// the LICENSE file. + +// Package envconfig implements decoding of environment variables based on a user +// defined specification. A typical use is using environment variables for +// configuration settings. +package envconfig diff --git a/toolkit/envconfig/env_os.go b/toolkit/envconfig/env_os.go new file mode 100644 index 00000000..eba07a6c --- /dev/null +++ b/toolkit/envconfig/env_os.go @@ -0,0 +1,7 @@ +// +build appengine go1.5 + +package envconfig + +import "os" + +var lookupEnv = os.LookupEnv diff --git a/toolkit/envconfig/env_syscall.go b/toolkit/envconfig/env_syscall.go new file mode 100644 index 00000000..42545400 --- /dev/null +++ b/toolkit/envconfig/env_syscall.go @@ -0,0 +1,7 @@ +// +build !appengine,!go1.5 + +package envconfig + +import "syscall" + +var lookupEnv = syscall.Getenv diff --git a/toolkit/envconfig/envconfig.go b/toolkit/envconfig/envconfig.go new file mode 100644 index 00000000..2aab8199 --- /dev/null +++ b/toolkit/envconfig/envconfig.go @@ -0,0 +1,591 @@ +// Copyright (c) 2013 Kelsey Hightower. All rights reserved. +// Use of this source code is governed by the MIT License that can be found in +// the LICENSE file. + +package envconfig + +import ( + "encoding" + "fmt" + "github.com/pkg/errors" + "math" + "os" + "reflect" + "regexp" + "strconv" + "strings" + "time" +) + +// ErrInvalidSpecification indicates that a specification is of the wrong type. +var ErrInvalidSpecification = errors.New("specification must be a struct pointer") + +var gatherRegexp = regexp.MustCompile("([^A-Z]+|[A-Z]+[^A-Z]+|[A-Z]+)") +var acronymRegexp = regexp.MustCompile("([A-Z]+)([A-Z][^A-Z]+)") + +// A ParseError occurs when an environment variable cannot be converted to +// the type required by a struct field during assignment. +type ParseError struct { + KeyName string + FieldName string + TypeName string + Value string + Err error +} + +// Decoder has the same semantics as Setter, but takes higher precedence. +// It is provided for historical compatibility. +type Decoder interface { + Decode(value string) error +} + +// Setter is implemented by types can self-deserialize values. +// Any type that implements flag.Value also implements Setter. +type Setter interface { + Set(value string) error +} + +func (e *ParseError) Error() string { + return fmt.Sprintf("envconfig.Process: assigning %[1]s to %[2]s: converting '%[3]s' to type %[4]s. details: %+v", e.KeyName, e.FieldName, e.Value, e.TypeName, e.Err) +} + +type SeparatorType string + +const ( + INDEX SeparatorType = "index" + COMMA SeparatorType = "comma" +) + +// varInfo maintains information about the configuration variable +type varInfo struct { + Name string + Alt string + Key string + Field reflect.Value + Tags reflect.StructTag + ValuesBy SeparatorType +} + +// GatherInfo gathers information about the specified struct +func gatherInfo(prefix string, spec interface{}) ([]varInfo, error) { + s := reflect.ValueOf(spec) + + if s.Kind() != reflect.Ptr { + return nil, ErrInvalidSpecification + } + s = s.Elem() + if s.Kind() != reflect.Struct { + return nil, ErrInvalidSpecification + } + typeOfSpec := s.Type() + + // over allocate an info array, we will extend if needed later + infos := make([]varInfo, 0, s.NumField()) + for i := 0; i < s.NumField(); i++ { + f := s.Field(i) + ftype := typeOfSpec.Field(i) + if !f.CanSet() || isTrue(ftype.Tag.Get("ignored")) { + continue + } + + for f.Kind() == reflect.Ptr { + if f.IsNil() { + if f.Type().Elem().Kind() != reflect.Struct { + // nil pointer to a non-struct: leave it alone + break + } + // nil pointer to struct: create a zero instance + f.Set(reflect.New(f.Type().Elem())) + } + f = f.Elem() + } + + // Capture information about the config variable + info := varInfo{ + Name: ftype.Name, + Field: f, + Tags: ftype.Tag, + Alt: strings.ToUpper(ftype.Tag.Get("envconfig")), + } + + // Default to the field name as the env var name (will be upcased) + info.Key = info.Name + + // Best effort to un-pick camel casing as separate words + if isTrue(ftype.Tag.Get("split_words")) { + words := gatherRegexp.FindAllStringSubmatch(ftype.Name, -1) + if len(words) > 0 { + var name []string + for _, words := range words { + if m := acronymRegexp.FindStringSubmatch(words[0]); len(m) == 3 { + name = append(name, m[1], m[2]) + } else { + name = append(name, words[0]) + } + } + + info.Key = strings.Join(name, "_") + } + } + if info.Alt != "" { + info.Key = info.Alt + } + if prefix != "" { + info.Key = fmt.Sprintf("%s_%s", prefix, info.Key) + } + info.Key = strings.ToUpper(info.Key) + info.ValuesBy = SeparatorType(ftype.Tag.Get("values_by")) + + infos = append(infos, info) + + if f.Kind() == reflect.Struct { + // honor Decode if present + if decoderFrom(f) == nil && setterFrom(f) == nil && textUnmarshaler(f) == nil && binaryUnmarshaler(f) == nil { + innerPrefix := prefix + if !ftype.Anonymous { + innerPrefix = info.Key + } + + embeddedPtr := f.Addr().Interface() + embeddedInfos, err := gatherInfo(innerPrefix, embeddedPtr) + if err != nil { + return nil, err + } + infos = append(infos[:len(infos)-1], embeddedInfos...) + + continue + } + } + } + return infos, nil +} + +// CheckDisallowed checks that no environment variables with the prefix are set +// that we don't know how or want to parse. This is likely only meaningful with +// a non-empty prefix. +func CheckDisallowed(prefix string, spec interface{}) error { + infos, err := gatherInfo(prefix, spec) + if err != nil { + return err + } + + vars := make(map[string]struct{}) + for _, info := range infos { + vars[info.Key] = struct{}{} + } + + if prefix != "" { + prefix = strings.ToUpper(prefix) + "_" + } + + for _, env := range os.Environ() { + if !strings.HasPrefix(env, prefix) { + continue + } + v := strings.SplitN(env, "=", 2)[0] + if _, found := vars[v]; !found { + return fmt.Errorf("unknown environment variable %s", v) + } + } + + return nil +} + +// Process populates the specified struct based on environment variables +func Process(prefix string, spec interface{}) error { + infos, err := gatherInfo(prefix, spec) + + for _, info := range infos { + + if info.ValuesBy == INDEX { + index := 0 + end := false + for !end { + key := info.Key + "_" + strconv.Itoa(index) + if end, err = doProcess(key, info); err != nil { + return errors.WithStack(err) + } + index++ + } + if info.Field.IsNil() || info.Field.IsZero() { + if err = checkRequired(info); err != nil { + return errors.WithStack(err) + } + def := info.Tags.Get("default") + if def != "" { + err = processFieldWithConfig(def, info.Field, ProcessConfig{ + ValuesBy: info.ValuesBy, + }) + } + } + continue + } + + if _, err = doProcess(info.Key, info); err != nil { + return errors.WithStack(err) + } + } + + return err +} + +func checkRequired(info varInfo) error { + def := info.Tags.Get("default") + req := info.Tags.Get("required") + if def == "" { + if isTrue(req) { + key := info.Key + if info.Alt != "" { + key = info.Alt + } + return fmt.Errorf("required key %s missing value", key) + } + } + return nil +} + +func doProcess(key string, info varInfo) (end bool, err error) { + // `os.Getenv` cannot differentiate between an explicitly set empty value + // and an unset value. `os.LookupEnv` is preferred to `syscall.Getenv`, + // but it is only available in go1.5 or newer. We're using Go build tags + // here to use os.LookupEnv for >=go1.5 + value, ok := lookupEnv(key) + + if info.ValuesBy == INDEX && !ok { + return true, nil + } + + if !ok && info.Alt != "" { + value, ok = lookupEnv(info.Alt) + } + + if info.ValuesBy != INDEX { + if !ok { + if err = checkRequired(info); err != nil { + return false, errors.WithStack(err) + } + def := info.Tags.Get("default") + if def == "" { + return false, nil + } + value = def + } + } + + err = processFieldWithConfig(value, info.Field, ProcessConfig{ + ValuesBy: info.ValuesBy, + }) + if err != nil { + return false, &ParseError{ + KeyName: info.Key, + FieldName: info.Name, + TypeName: info.Field.Type().String(), + Value: value, + Err: errors.WithStack(err), + } + } + + return false, nil +} + +// MustProcess is the same as Process but panics if an error occurs +func MustProcess(prefix string, spec interface{}) { + if err := Process(prefix, spec); err != nil { + panic(err) + } +} + +func processField(value string, field reflect.Value) error { + typ := field.Type() + + decoder := decoderFrom(field) + if decoder != nil { + return decoder.Decode(value) + } + // look for Set method if Decode not defined + setter := setterFrom(field) + if setter != nil { + return setter.Set(value) + } + + if t := textUnmarshaler(field); t != nil { + return t.UnmarshalText([]byte(value)) + } + + if b := binaryUnmarshaler(field); b != nil { + return b.UnmarshalBinary([]byte(value)) + } + + if typ.Kind() == reflect.Ptr { + typ = typ.Elem() + if field.IsNil() { + field.Set(reflect.New(typ)) + } + field = field.Elem() + } + + switch typ.Kind() { + case reflect.String: + field.SetString(value) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + var ( + val int64 + err error + ) + if field.Kind() == reflect.Int64 && typ.PkgPath() == "time" && typ.Name() == "Duration" { + var d time.Duration + d, err = time.ParseDuration(value) + val = int64(d) + } else { + val, err = strconv.ParseInt(value, 0, typ.Bits()) + } + if err != nil { + return err + } + + field.SetInt(val) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + val, err := strconv.ParseUint(value, 0, typ.Bits()) + if err != nil { + return err + } + field.SetUint(val) + case reflect.Bool: + val, err := strconv.ParseBool(value) + if err != nil { + return err + } + field.SetBool(val) + case reflect.Float32, reflect.Float64: + val, err := strconv.ParseFloat(value, typ.Bits()) + if err != nil { + return err + } + field.SetFloat(val) + case reflect.Slice: + sl := reflect.MakeSlice(typ, 0, 0) + if typ.Elem().Kind() == reflect.Uint8 { + sl = reflect.ValueOf([]byte(value)) + } else if strings.TrimSpace(value) != "" { + vals := strings.Split(value, ",") + sl = reflect.MakeSlice(typ, len(vals), len(vals)) + for i, val := range vals { + err := processField(val, sl.Index(i)) + if err != nil { + return err + } + } + } + field.Set(sl) + case reflect.Map: + mp := reflect.MakeMap(typ) + if strings.TrimSpace(value) != "" { + pairs := strings.Split(value, ",") + for _, pair := range pairs { + kvpair := strings.Split(pair, ":") + if len(kvpair) != 2 { + return fmt.Errorf("invalid map item: %q", pair) + } + k := reflect.New(typ.Key()).Elem() + err := processField(kvpair[0], k) + if err != nil { + return err + } + v := reflect.New(typ.Elem()).Elem() + err = processField(kvpair[1], v) + if err != nil { + return err + } + mp.SetMapIndex(k, v) + } + } + field.Set(mp) + } + + return nil +} + +type ProcessConfig struct { + ValuesBy SeparatorType +} + +const ( + CAP_GROW_STEP = 8 +) + +func processFieldWithConfig(value string, field reflect.Value, config ProcessConfig) error { + typ := field.Type() + + decoder := decoderFrom(field) + if decoder != nil { + return decoder.Decode(value) + } + // look for Set method if Decode not defined + setter := setterFrom(field) + if setter != nil { + return setter.Set(value) + } + + if t := textUnmarshaler(field); t != nil { + return t.UnmarshalText([]byte(value)) + } + + if b := binaryUnmarshaler(field); b != nil { + return b.UnmarshalBinary([]byte(value)) + } + + if typ.Kind() == reflect.Ptr { + typ = typ.Elem() + if field.IsNil() { + field.Set(reflect.New(typ)) + } + field = field.Elem() + } + + switch typ.Kind() { + case reflect.String: + field.SetString(value) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + var ( + val int64 + err error + ) + if field.Kind() == reflect.Int64 && typ.PkgPath() == "time" && typ.Name() == "Duration" { + var d time.Duration + d, err = time.ParseDuration(value) + val = int64(d) + } else { + val, err = strconv.ParseInt(value, 0, typ.Bits()) + } + if err != nil { + return errors.WithStack(err) + } + + field.SetInt(val) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + val, err := strconv.ParseUint(value, 0, typ.Bits()) + if err != nil { + return errors.WithStack(err) + } + field.SetUint(val) + case reflect.Bool: + val, err := strconv.ParseBool(value) + if err != nil { + return errors.WithStack(err) + } + field.SetBool(val) + case reflect.Float32, reflect.Float64: + val, err := strconv.ParseFloat(value, typ.Bits()) + if err != nil { + return errors.WithStack(err) + } + field.SetFloat(val) + case reflect.Slice: + if config.ValuesBy == INDEX { + if field.IsNil() || field.IsZero() { + sl := reflect.MakeSlice(typ, 0, 0) + if typ.Elem().Kind() == reflect.Uint8 { + return errors.WithStack(errors.New("Not support uint8 slice field to be processed with index values_by tag")) + } else if strings.TrimSpace(value) != "" { + sl = reflect.MakeSlice(typ, 1, CAP_GROW_STEP) + err := processFieldWithConfig(value, sl.Index(0), config) + if err != nil { + return errors.WithStack(err) + } + } + field.Set(sl) + } else { + size := field.Len() + if size >= int(math.Floor(float64(field.Cap())*0.75)) { + field.Grow(CAP_GROW_STEP) + } + field.SetLen(size + 1) + if typ.Elem().Kind() == reflect.Uint8 { + return errors.WithStack(errors.New("Not support uint8 slice field to be processed with index values_by tag")) + } else if strings.TrimSpace(value) != "" { + err := processFieldWithConfig(value, field.Index(size), config) + if err != nil { + return errors.WithStack(err) + } + } + } + } else { + sl := reflect.MakeSlice(typ, 0, 0) + if typ.Elem().Kind() == reflect.Uint8 { + sl = reflect.ValueOf([]byte(value)) + } else if strings.TrimSpace(value) != "" { + vals := strings.Split(value, ",") + sl = reflect.MakeSlice(typ, len(vals), len(vals)) + for i, val := range vals { + err := processFieldWithConfig(val, sl.Index(i), config) + if err != nil { + return errors.WithStack(err) + } + } + } + field.Set(sl) + } + case reflect.Map: + mp := reflect.MakeMap(typ) + if strings.TrimSpace(value) != "" { + pairs := strings.Split(value, ",") + for _, pair := range pairs { + kvpair := strings.Split(pair, ":") + if len(kvpair) != 2 { + return fmt.Errorf("invalid map item: %q", pair) + } + k := reflect.New(typ.Key()).Elem() + err := processFieldWithConfig(kvpair[0], k, config) + if err != nil { + return errors.WithStack(err) + } + v := reflect.New(typ.Elem()).Elem() + err = processFieldWithConfig(kvpair[1], v, config) + if err != nil { + return errors.WithStack(err) + } + mp.SetMapIndex(k, v) + } + } + field.Set(mp) + } + + return nil +} + +func interfaceFrom(field reflect.Value, fn func(interface{}, *bool)) { + // it may be impossible for a struct field to fail this check + if !field.CanInterface() { + return + } + var ok bool + fn(field.Interface(), &ok) + if !ok && field.CanAddr() { + fn(field.Addr().Interface(), &ok) + } +} + +func decoderFrom(field reflect.Value) (d Decoder) { + interfaceFrom(field, func(v interface{}, ok *bool) { d, *ok = v.(Decoder) }) + return d +} + +func setterFrom(field reflect.Value) (s Setter) { + interfaceFrom(field, func(v interface{}, ok *bool) { s, *ok = v.(Setter) }) + return s +} + +func textUnmarshaler(field reflect.Value) (t encoding.TextUnmarshaler) { + interfaceFrom(field, func(v interface{}, ok *bool) { t, *ok = v.(encoding.TextUnmarshaler) }) + return t +} + +func binaryUnmarshaler(field reflect.Value) (b encoding.BinaryUnmarshaler) { + interfaceFrom(field, func(v interface{}, ok *bool) { b, *ok = v.(encoding.BinaryUnmarshaler) }) + return b +} + +func isTrue(s string) bool { + b, _ := strconv.ParseBool(s) + return b +} diff --git a/toolkit/envconfig/envconfig_1.8_test.go b/toolkit/envconfig/envconfig_1.8_test.go new file mode 100644 index 00000000..8dfcc6cb --- /dev/null +++ b/toolkit/envconfig/envconfig_1.8_test.go @@ -0,0 +1,68 @@ +// +build go1.8 + +package envconfig + +import ( + "errors" + "net/url" + "os" + "testing" +) + +type SpecWithURL struct { + UrlValue url.URL + UrlPointer *url.URL +} + +func TestParseURL(t *testing.T) { + var s SpecWithURL + + os.Clearenv() + os.Setenv("ENV_CONFIG_URLVALUE", "https://github.com/kelseyhightower/envconfig") + os.Setenv("ENV_CONFIG_URLPOINTER", "https://github.com/kelseyhightower/envconfig") + + err := Process("env_config", &s) + if err != nil { + t.Fatal("unexpected error:", err) + } + + u, err := url.Parse("https://github.com/kelseyhightower/envconfig") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if s.UrlValue != *u { + t.Errorf("expected %q, got %q", u, s.UrlValue.String()) + } + + if *s.UrlPointer != *u { + t.Errorf("expected %q, got %q", u, s.UrlPointer) + } +} + +func TestParseURLError(t *testing.T) { + var s SpecWithURL + + os.Clearenv() + os.Setenv("ENV_CONFIG_URLPOINTER", "http_://foo") + + err := Process("env_config", &s) + + v, ok := err.(*ParseError) + if !ok { + t.Fatalf("expected ParseError, got %T %v", err, err) + } + if v.FieldName != "UrlPointer" { + t.Errorf("expected %s, got %v", "UrlPointer", v.FieldName) + } + + expectedUnerlyingError := url.Error{ + Op: "parse", + URL: "http_://foo", + Err: errors.New("first path segment in URL cannot contain colon"), + } + + if v.Err.Error() != expectedUnerlyingError.Error() { + t.Errorf("expected %q, got %q", expectedUnerlyingError, v.Err) + } +} diff --git a/toolkit/envconfig/envconfig_test.go b/toolkit/envconfig/envconfig_test.go new file mode 100644 index 00000000..2df46dfb --- /dev/null +++ b/toolkit/envconfig/envconfig_test.go @@ -0,0 +1,935 @@ +// Copyright (c) 2013 Kelsey Hightower. All rights reserved. +// Use of this source code is governed by the MIT License that can be found in +// the LICENSE file. + +package envconfig + +import ( + "flag" + "fmt" + "github.com/pkg/errors" + "net/url" + "os" + "strings" + "testing" + "time" +) + +type HonorDecodeInStruct struct { + Value string +} + +func (h *HonorDecodeInStruct) Decode(env string) error { + h.Value = "decoded" + return nil +} + +type CustomURL struct { + Value *url.URL +} + +func (cu *CustomURL) UnmarshalBinary(data []byte) error { + u, err := url.Parse(string(data)) + cu.Value = u + return err +} + +type Specification struct { + Embedded `desc:"can we document a struct"` + EmbeddedButIgnored `ignored:"true"` + Debug bool + Port int + Rate float32 + User string + TTL uint32 + Timeout time.Duration + AdminUsers []string + Users []string `values_by:"index" required:"true"` + Fruits []string `values_by:"index" default:"apple"` + MagicNumbers []int + EmptyNumbers []int + ByteSlice []byte + ColorCodes map[string]int + MultiWordVar string + MultiWordVarWithAutoSplit uint32 `split_words:"true"` + MultiWordACRWithAutoSplit uint32 `split_words:"true"` + SomePointer *string + SomePointerWithDefault *string `default:"foo2baz" desc:"foorbar is the word"` + MultiWordVarWithAlt string `envconfig:"MULTI_WORD_VAR_WITH_ALT" desc:"what alt"` + MultiWordVarWithLowerCaseAlt string `envconfig:"multi_word_var_with_lower_case_alt"` + NoPrefixWithAlt string `envconfig:"SERVICE_HOST"` + DefaultVar string `default:"foobar"` + RequiredVar string `required:"True"` + NoPrefixDefault string `envconfig:"BROKER" default:"127.0.0.1"` + RequiredDefault string `required:"true" default:"foo2bar"` + Ignored string `ignored:"true"` + NestedSpecification struct { + Property string `envconfig:"inner"` + PropertyWithDefault string `default:"fuzzybydefault"` + } `envconfig:"outer"` + AfterNested string + DecodeStruct HonorDecodeInStruct `envconfig:"honor"` + Datetime time.Time + MapField map[string]string `default:"one:two,three:four"` + UrlValue CustomURL + UrlPointer *CustomURL +} + +type Embedded struct { + Enabled bool `desc:"some embedded value"` + EmbeddedPort int + MultiWordVar string + MultiWordVarWithAlt string `envconfig:"MULTI_WITH_DIFFERENT_ALT"` + EmbeddedAlt string `envconfig:"EMBEDDED_WITH_ALT"` + EmbeddedIgnored string `ignored:"true"` +} + +type EmbeddedButIgnored struct { + FirstEmbeddedButIgnored string + SecondEmbeddedButIgnored string +} + +func TestProcess(t *testing.T) { + var s Specification + os.Clearenv() + os.Setenv("ENV_CONFIG_USERS_0", "John1") + os.Setenv("ENV_CONFIG_DEBUG", "true") + os.Setenv("ENV_CONFIG_PORT", "8080") + os.Setenv("ENV_CONFIG_RATE", "0.5") + os.Setenv("ENV_CONFIG_USER", "Kelsey") + os.Setenv("ENV_CONFIG_TIMEOUT", "2m") + os.Setenv("ENV_CONFIG_ADMINUSERS", "John,Adam,Will") + os.Setenv("ENV_CONFIG_MAGICNUMBERS", "5,10,20") + os.Setenv("ENV_CONFIG_EMPTYNUMBERS", "") + os.Setenv("ENV_CONFIG_BYTESLICE", "this is a test value") + os.Setenv("ENV_CONFIG_COLORCODES", "red:1,green:2,blue:3") + os.Setenv("SERVICE_HOST", "127.0.0.1") + os.Setenv("ENV_CONFIG_TTL", "30") + os.Setenv("ENV_CONFIG_REQUIREDVAR", "foo") + os.Setenv("ENV_CONFIG_IGNORED", "was-not-ignored") + os.Setenv("ENV_CONFIG_OUTER_INNER", "iamnested") + os.Setenv("ENV_CONFIG_AFTERNESTED", "after") + os.Setenv("ENV_CONFIG_HONOR", "honor") + os.Setenv("ENV_CONFIG_DATETIME", "2016-08-16T18:57:05Z") + os.Setenv("ENV_CONFIG_MULTI_WORD_VAR_WITH_AUTO_SPLIT", "24") + os.Setenv("ENV_CONFIG_MULTI_WORD_ACR_WITH_AUTO_SPLIT", "25") + os.Setenv("ENV_CONFIG_URLVALUE", "https://github.com/kelseyhightower/envconfig") + os.Setenv("ENV_CONFIG_URLPOINTER", "https://github.com/kelseyhightower/envconfig") + err := Process("env_config", &s) + if err != nil { + t.Error(err.Error()) + } + if s.NoPrefixWithAlt != "127.0.0.1" { + t.Errorf("expected %v, got %v", "127.0.0.1", s.NoPrefixWithAlt) + } + if !s.Debug { + t.Errorf("expected %v, got %v", true, s.Debug) + } + if s.Port != 8080 { + t.Errorf("expected %d, got %v", 8080, s.Port) + } + if s.Rate != 0.5 { + t.Errorf("expected %f, got %v", 0.5, s.Rate) + } + if s.TTL != 30 { + t.Errorf("expected %d, got %v", 30, s.TTL) + } + if s.User != "Kelsey" { + t.Errorf("expected %s, got %s", "Kelsey", s.User) + } + if s.Timeout != 2*time.Minute { + t.Errorf("expected %s, got %s", 2*time.Minute, s.Timeout) + } + if s.RequiredVar != "foo" { + t.Errorf("expected %s, got %s", "foo", s.RequiredVar) + } + if len(s.AdminUsers) != 3 || + s.AdminUsers[0] != "John" || + s.AdminUsers[1] != "Adam" || + s.AdminUsers[2] != "Will" { + t.Errorf("expected %#v, got %#v", []string{"John", "Adam", "Will"}, s.AdminUsers) + } + if len(s.MagicNumbers) != 3 || + s.MagicNumbers[0] != 5 || + s.MagicNumbers[1] != 10 || + s.MagicNumbers[2] != 20 { + t.Errorf("expected %#v, got %#v", []int{5, 10, 20}, s.MagicNumbers) + } + if len(s.EmptyNumbers) != 0 { + t.Errorf("expected %#v, got %#v", []int{}, s.EmptyNumbers) + } + expected := "this is a test value" + if string(s.ByteSlice) != expected { + t.Errorf("expected %v, got %v", expected, string(s.ByteSlice)) + } + if s.Ignored != "" { + t.Errorf("expected empty string, got %#v", s.Ignored) + } + + if len(s.ColorCodes) != 3 || + s.ColorCodes["red"] != 1 || + s.ColorCodes["green"] != 2 || + s.ColorCodes["blue"] != 3 { + t.Errorf( + "expected %#v, got %#v", + map[string]int{ + "red": 1, + "green": 2, + "blue": 3, + }, + s.ColorCodes, + ) + } + + if s.NestedSpecification.Property != "iamnested" { + t.Errorf("expected '%s' string, got %#v", "iamnested", s.NestedSpecification.Property) + } + + if s.NestedSpecification.PropertyWithDefault != "fuzzybydefault" { + t.Errorf("expected default '%s' string, got %#v", "fuzzybydefault", s.NestedSpecification.PropertyWithDefault) + } + + if s.AfterNested != "after" { + t.Errorf("expected default '%s' string, got %#v", "after", s.AfterNested) + } + + if s.DecodeStruct.Value != "decoded" { + t.Errorf("expected default '%s' string, got %#v", "decoded", s.DecodeStruct.Value) + } + + if expected := time.Date(2016, 8, 16, 18, 57, 05, 0, time.UTC); !s.Datetime.Equal(expected) { + t.Errorf("expected %s, got %s", expected.Format(time.RFC3339), s.Datetime.Format(time.RFC3339)) + } + + if s.MultiWordVarWithAutoSplit != 24 { + t.Errorf("expected %q, got %q", 24, s.MultiWordVarWithAutoSplit) + } + + if s.MultiWordACRWithAutoSplit != 25 { + t.Errorf("expected %d, got %d", 25, s.MultiWordACRWithAutoSplit) + } + + u, err := url.Parse("https://github.com/kelseyhightower/envconfig") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if *s.UrlValue.Value != *u { + t.Errorf("expected %q, got %q", u, s.UrlValue.Value.String()) + } + + if *s.UrlPointer.Value != *u { + t.Errorf("expected %q, got %q", u, s.UrlPointer.Value.String()) + } +} + +func TestProcessValuesBy(t *testing.T) { + var s Specification + os.Clearenv() + os.Setenv("ENV_CONFIG_USERS_0", "John1") + os.Setenv("ENV_CONFIG_USERS_1", "Adam2") + os.Setenv("ENV_CONFIG_USERS_2", "Will3") + os.Setenv("ENV_CONFIG_USERS_3", "Will4") + os.Setenv("ENV_CONFIG_USERS_4", "Will5") + os.Setenv("ENV_CONFIG_USERS_5", "Will6") + os.Setenv("ENV_CONFIG_USERS_6", "Will7") + os.Setenv("ENV_CONFIG_USERS_7", "Will8") + os.Setenv("ENV_CONFIG_USERS_8", "Will9") + os.Setenv("ENV_CONFIG_USERS_9", "Will10") + os.Setenv("ENV_CONFIG_USERS_10", "Will11") + _ = Process("env_config", &s) + if len(s.Users) != 11 { + t.Errorf("expected %#v, got %#v", []string{"John1", "Adam2", "Will3", "Will4", "Will5", "Will6", "Will7", "Will8", "Will9", "Will10", "Will11"}, s.Users) + } +} + +func TestProcessValuesByRequired(t *testing.T) { + var s Specification + os.Clearenv() + err := Process("env_config", &s) + if err == nil { + t.Errorf("should have error") + } + if err.Error() != "required key ENV_CONFIG_USERS missing value" { + t.Errorf("error not right") + } +} + +func TestProcessValuesByDefault(t *testing.T) { + var s Specification + os.Clearenv() + os.Setenv("ENV_CONFIG_USERS_0", "John1") + os.Setenv("ENV_CONFIG_REQUIREDVAR", "foo") + err := Process("env_config", &s) + if err != nil { + t.Errorf("should not have error: %s", err) + } + if s.Fruits[0] != "apple" { + t.Errorf("fruits should have one and only one element apple") + } +} + +func TestParseErrorBool(t *testing.T) { + var s Specification + os.Clearenv() + os.Setenv("ENV_CONFIG_DEBUG", "string") + os.Setenv("ENV_CONFIG_REQUIREDVAR", "foo") + err := Process("env_config", &s) + v := &ParseError{} + ok := errors.As(err, &v) + if !ok { + t.Errorf("expected ParseError, got %v", v) + } + if v.FieldName != "Debug" { + t.Errorf("expected %s, got %v", "Debug", v.FieldName) + } + if s.Debug != false { + t.Errorf("expected %v, got %v", false, s.Debug) + } +} + +func TestParseErrorFloat32(t *testing.T) { + var s Specification + os.Clearenv() + os.Setenv("ENV_CONFIG_RATE", "string") + os.Setenv("ENV_CONFIG_REQUIREDVAR", "foo") + err := Process("env_config", &s) + v := &ParseError{} + ok := errors.As(err, &v) + if !ok { + t.Errorf("expected ParseError, got %v", v) + } + if v.FieldName != "Rate" { + t.Errorf("expected %s, got %v", "Rate", v.FieldName) + } + if s.Rate != 0 { + t.Errorf("expected %v, got %v", 0, s.Rate) + } +} + +func TestParseErrorInt(t *testing.T) { + var s Specification + os.Clearenv() + os.Setenv("ENV_CONFIG_PORT", "string") + os.Setenv("ENV_CONFIG_REQUIREDVAR", "foo") + err := Process("env_config", &s) + v := &ParseError{} + ok := errors.As(err, &v) + if !ok { + t.Errorf("expected ParseError, got %v", v) + } + if v.FieldName != "Port" { + t.Errorf("expected %s, got %v", "Port", v.FieldName) + } + if s.Port != 0 { + t.Errorf("expected %v, got %v", 0, s.Port) + } +} + +func TestParseErrorUint(t *testing.T) { + var s Specification + os.Clearenv() + os.Setenv("ENV_CONFIG_TTL", "-30") + err := Process("env_config", &s) + v := &ParseError{} + ok := errors.As(err, &v) + if !ok { + t.Errorf("expected ParseError, got %v", v) + } + if v.FieldName != "TTL" { + t.Errorf("expected %s, got %v", "TTL", v.FieldName) + } + if s.TTL != 0 { + t.Errorf("expected %v, got %v", 0, s.TTL) + } +} + +func TestParseErrorSplitWords(t *testing.T) { + var s Specification + os.Clearenv() + os.Setenv("ENV_CONFIG_MULTI_WORD_VAR_WITH_AUTO_SPLIT", "shakespeare") + os.Setenv("ENV_CONFIG_USERS_0", "John1") + err := Process("env_config", &s) + v := &ParseError{} + ok := errors.As(err, &v) + if !ok { + t.Errorf("expected ParseError, got %v", v) + } + if v.FieldName != "MultiWordVarWithAutoSplit" { + t.Errorf("expected %s, got %v", "", v.FieldName) + } + if s.MultiWordVarWithAutoSplit != 0 { + t.Errorf("expected %v, got %v", 0, s.MultiWordVarWithAutoSplit) + } +} + +func TestErrInvalidSpecification(t *testing.T) { + m := make(map[string]string) + err := Process("env_config", &m) + if err != ErrInvalidSpecification { + t.Errorf("expected %v, got %v", ErrInvalidSpecification, err) + } +} + +func TestUnsetVars(t *testing.T) { + var s Specification + os.Clearenv() + os.Setenv("USER", "foo") + os.Setenv("ENV_CONFIG_REQUIREDVAR", "foo") + os.Setenv("ENV_CONFIG_USERS_0", "John1") + if err := Process("env_config", &s); err != nil { + t.Error(err.Error()) + } + + // If the var is not defined the non-prefixed version should not be used + // unless the struct tag says so + if s.User != "" { + t.Errorf("expected %q, got %q", "", s.User) + } +} + +func TestAlternateVarNames(t *testing.T) { + var s Specification + os.Clearenv() + os.Setenv("ENV_CONFIG_MULTI_WORD_VAR", "foo") + os.Setenv("ENV_CONFIG_MULTI_WORD_VAR_WITH_ALT", "bar") + os.Setenv("ENV_CONFIG_MULTI_WORD_VAR_WITH_LOWER_CASE_ALT", "baz") + os.Setenv("ENV_CONFIG_REQUIREDVAR", "foo") + os.Setenv("ENV_CONFIG_USERS_0", "John1") + if err := Process("env_config", &s); err != nil { + t.Error(err.Error()) + } + + // Setting the alt version of the var in the environment has no effect if + // the struct tag is not supplied + if s.MultiWordVar != "" { + t.Errorf("expected %q, got %q", "", s.MultiWordVar) + } + + // Setting the alt version of the var in the environment correctly sets + // the value if the struct tag IS supplied + if s.MultiWordVarWithAlt != "bar" { + t.Errorf("expected %q, got %q", "bar", s.MultiWordVarWithAlt) + } + + // Alt value is not case sensitive and is treated as all uppercase + if s.MultiWordVarWithLowerCaseAlt != "baz" { + t.Errorf("expected %q, got %q", "baz", s.MultiWordVarWithLowerCaseAlt) + } +} + +func TestRequiredVar(t *testing.T) { + var s Specification + os.Clearenv() + os.Setenv("ENV_CONFIG_REQUIREDVAR", "foobar") + os.Setenv("ENV_CONFIG_USERS_0", "John1") + if err := Process("env_config", &s); err != nil { + t.Error(err.Error()) + } + + if s.RequiredVar != "foobar" { + t.Errorf("expected %s, got %s", "foobar", s.RequiredVar) + } +} + +func TestRequiredMissing(t *testing.T) { + var s Specification + os.Clearenv() + + err := Process("env_config", &s) + if err == nil { + t.Error("no failure when missing required variable") + } +} + +func TestBlankDefaultVar(t *testing.T) { + var s Specification + os.Clearenv() + os.Setenv("ENV_CONFIG_REQUIREDVAR", "requiredvalue") + os.Setenv("ENV_CONFIG_USERS_0", "John1") + if err := Process("env_config", &s); err != nil { + t.Error(err.Error()) + } + + if s.DefaultVar != "foobar" { + t.Errorf("expected %s, got %s", "foobar", s.DefaultVar) + } + + if *s.SomePointerWithDefault != "foo2baz" { + t.Errorf("expected %s, got %s", "foo2baz", *s.SomePointerWithDefault) + } +} + +func TestNonBlankDefaultVar(t *testing.T) { + var s Specification + os.Clearenv() + os.Setenv("ENV_CONFIG_DEFAULTVAR", "nondefaultval") + os.Setenv("ENV_CONFIG_REQUIREDVAR", "requiredvalue") + os.Setenv("ENV_CONFIG_USERS_0", "John1") + if err := Process("env_config", &s); err != nil { + t.Error(err.Error()) + } + + if s.DefaultVar != "nondefaultval" { + t.Errorf("expected %s, got %s", "nondefaultval", s.DefaultVar) + } +} + +func TestExplicitBlankDefaultVar(t *testing.T) { + var s Specification + os.Clearenv() + os.Setenv("ENV_CONFIG_DEFAULTVAR", "") + os.Setenv("ENV_CONFIG_REQUIREDVAR", "") + os.Setenv("ENV_CONFIG_USERS_0", "John1") + if err := Process("env_config", &s); err != nil { + t.Error(err.Error()) + } + + if s.DefaultVar != "" { + t.Errorf("expected %s, got %s", "\"\"", s.DefaultVar) + } +} + +func TestAlternateNameDefaultVar(t *testing.T) { + var s Specification + os.Clearenv() + os.Setenv("BROKER", "betterbroker") + os.Setenv("ENV_CONFIG_REQUIREDVAR", "foo") + os.Setenv("ENV_CONFIG_USERS_0", "John1") + if err := Process("env_config", &s); err != nil { + t.Error(err.Error()) + } + + if s.NoPrefixDefault != "betterbroker" { + t.Errorf("expected %q, got %q", "betterbroker", s.NoPrefixDefault) + } + + os.Clearenv() + os.Setenv("ENV_CONFIG_REQUIREDVAR", "foo") + if err := Process("env_config", &s); err != nil { + t.Error(err.Error()) + } + + if s.NoPrefixDefault != "127.0.0.1" { + t.Errorf("expected %q, got %q", "127.0.0.1", s.NoPrefixDefault) + } +} + +func TestRequiredDefault(t *testing.T) { + var s Specification + os.Clearenv() + os.Setenv("ENV_CONFIG_REQUIREDVAR", "foo") + os.Setenv("ENV_CONFIG_USERS_0", "John1") + if err := Process("env_config", &s); err != nil { + t.Error(err.Error()) + } + + if s.RequiredDefault != "foo2bar" { + t.Errorf("expected %q, got %q", "foo2bar", s.RequiredDefault) + } +} + +func TestPointerFieldBlank(t *testing.T) { + var s Specification + os.Clearenv() + os.Setenv("ENV_CONFIG_REQUIREDVAR", "foo") + os.Setenv("ENV_CONFIG_USERS_0", "John1") + if err := Process("env_config", &s); err != nil { + t.Error(err.Error()) + } + + if s.SomePointer != nil { + t.Errorf("expected , got %q", *s.SomePointer) + } +} + +func TestEmptyMapFieldOverride(t *testing.T) { + var s Specification + os.Clearenv() + os.Setenv("ENV_CONFIG_REQUIREDVAR", "foo") + os.Setenv("ENV_CONFIG_MAPFIELD", "") + os.Setenv("ENV_CONFIG_USERS_0", "John1") + if err := Process("env_config", &s); err != nil { + t.Error(err.Error()) + } + + if s.MapField == nil { + t.Error("expected empty map, got ") + } + + if len(s.MapField) != 0 { + t.Errorf("expected empty map, got map of size %d", len(s.MapField)) + } +} + +func TestMustProcess(t *testing.T) { + var s Specification + os.Clearenv() + os.Setenv("ENV_CONFIG_DEBUG", "true") + os.Setenv("ENV_CONFIG_PORT", "8080") + os.Setenv("ENV_CONFIG_RATE", "0.5") + os.Setenv("ENV_CONFIG_USER", "Kelsey") + os.Setenv("SERVICE_HOST", "127.0.0.1") + os.Setenv("ENV_CONFIG_REQUIREDVAR", "foo") + os.Setenv("ENV_CONFIG_USERS_0", "John1") + MustProcess("env_config", &s) + + defer func() { + if err := recover(); err != nil { + return + } + + t.Error("expected panic") + }() + m := make(map[string]string) + MustProcess("env_config", &m) +} + +func TestEmbeddedStruct(t *testing.T) { + var s Specification + os.Clearenv() + os.Setenv("ENV_CONFIG_REQUIREDVAR", "required") + os.Setenv("ENV_CONFIG_ENABLED", "true") + os.Setenv("ENV_CONFIG_EMBEDDEDPORT", "1234") + os.Setenv("ENV_CONFIG_MULTIWORDVAR", "foo") + os.Setenv("ENV_CONFIG_MULTI_WORD_VAR_WITH_ALT", "bar") + os.Setenv("ENV_CONFIG_MULTI_WITH_DIFFERENT_ALT", "baz") + os.Setenv("ENV_CONFIG_EMBEDDED_WITH_ALT", "foobar") + os.Setenv("ENV_CONFIG_SOMEPOINTER", "foobaz") + os.Setenv("ENV_CONFIG_EMBEDDED_IGNORED", "was-not-ignored") + os.Setenv("ENV_CONFIG_USERS_0", "John1") + if err := Process("env_config", &s); err != nil { + t.Error(err.Error()) + } + if !s.Enabled { + t.Errorf("expected %v, got %v", true, s.Enabled) + } + if s.EmbeddedPort != 1234 { + t.Errorf("expected %d, got %v", 1234, s.EmbeddedPort) + } + if s.MultiWordVar != "foo" { + t.Errorf("expected %s, got %s", "foo", s.MultiWordVar) + } + if s.Embedded.MultiWordVar != "foo" { + t.Errorf("expected %s, got %s", "foo", s.Embedded.MultiWordVar) + } + if s.MultiWordVarWithAlt != "bar" { + t.Errorf("expected %s, got %s", "bar", s.MultiWordVarWithAlt) + } + if s.Embedded.MultiWordVarWithAlt != "baz" { + t.Errorf("expected %s, got %s", "baz", s.Embedded.MultiWordVarWithAlt) + } + if s.EmbeddedAlt != "foobar" { + t.Errorf("expected %s, got %s", "foobar", s.EmbeddedAlt) + } + if *s.SomePointer != "foobaz" { + t.Errorf("expected %s, got %s", "foobaz", *s.SomePointer) + } + if s.EmbeddedIgnored != "" { + t.Errorf("expected empty string, got %#v", s.Ignored) + } +} + +func TestEmbeddedButIgnoredStruct(t *testing.T) { + var s Specification + os.Clearenv() + os.Setenv("ENV_CONFIG_REQUIREDVAR", "required") + os.Setenv("ENV_CONFIG_FIRSTEMBEDDEDBUTIGNORED", "was-not-ignored") + os.Setenv("ENV_CONFIG_SECONDEMBEDDEDBUTIGNORED", "was-not-ignored") + os.Setenv("ENV_CONFIG_USERS_0", "John1") + if err := Process("env_config", &s); err != nil { + t.Error(err.Error()) + } + if s.FirstEmbeddedButIgnored != "" { + t.Errorf("expected empty string, got %#v", s.Ignored) + } + if s.SecondEmbeddedButIgnored != "" { + t.Errorf("expected empty string, got %#v", s.Ignored) + } +} + +func TestNonPointerFailsProperly(t *testing.T) { + var s Specification + os.Clearenv() + os.Setenv("ENV_CONFIG_REQUIREDVAR", "snap") + + err := Process("env_config", s) + if err != ErrInvalidSpecification { + t.Errorf("non-pointer should fail with ErrInvalidSpecification, was instead %s", err) + } +} + +func TestCustomValueFields(t *testing.T) { + var s struct { + Foo string + Bar bracketed + Baz quoted + Struct setterStruct + } + + // Set would panic when the receiver is nil, + // so make sure it has an initial value to replace. + s.Baz = quoted{new(bracketed)} + + os.Clearenv() + os.Setenv("ENV_CONFIG_FOO", "foo") + os.Setenv("ENV_CONFIG_BAR", "bar") + os.Setenv("ENV_CONFIG_BAZ", "baz") + os.Setenv("ENV_CONFIG_STRUCT", "inner") + + if err := Process("env_config", &s); err != nil { + t.Error(err.Error()) + } + + if want := "foo"; s.Foo != want { + t.Errorf("foo: got %#q, want %#q", s.Foo, want) + } + + if want := "[bar]"; s.Bar.String() != want { + t.Errorf("bar: got %#q, want %#q", s.Bar, want) + } + + if want := `["baz"]`; s.Baz.String() != want { + t.Errorf(`baz: got %#q, want %#q`, s.Baz, want) + } + + if want := `setterstruct{"inner"}`; s.Struct.Inner != want { + t.Errorf(`Struct.Inner: got %#q, want %#q`, s.Struct.Inner, want) + } +} + +func TestCustomPointerFields(t *testing.T) { + var s struct { + Foo string + Bar *bracketed + Baz *quoted + Struct *setterStruct + } + + // Set would panic when the receiver is nil, + // so make sure they have initial values to replace. + s.Bar = new(bracketed) + s.Baz = "ed{new(bracketed)} + + os.Clearenv() + os.Setenv("ENV_CONFIG_FOO", "foo") + os.Setenv("ENV_CONFIG_BAR", "bar") + os.Setenv("ENV_CONFIG_BAZ", "baz") + os.Setenv("ENV_CONFIG_STRUCT", "inner") + + if err := Process("env_config", &s); err != nil { + t.Error(err.Error()) + } + + if want := "foo"; s.Foo != want { + t.Errorf("foo: got %#q, want %#q", s.Foo, want) + } + + if want := "[bar]"; s.Bar.String() != want { + t.Errorf("bar: got %#q, want %#q", s.Bar, want) + } + + if want := `["baz"]`; s.Baz.String() != want { + t.Errorf(`baz: got %#q, want %#q`, s.Baz, want) + } + + if want := `setterstruct{"inner"}`; s.Struct.Inner != want { + t.Errorf(`Struct.Inner: got %#q, want %#q`, s.Struct.Inner, want) + } +} + +func TestEmptyPrefixUsesFieldNames(t *testing.T) { + var s Specification + os.Clearenv() + os.Setenv("REQUIREDVAR", "foo") + os.Setenv("USERS_0", "John1") + err := Process("", &s) + if err != nil { + t.Errorf("Process failed: %s", err) + } + + if s.RequiredVar != "foo" { + t.Errorf( + `RequiredVar not populated correctly: expected "foo", got %q`, + s.RequiredVar, + ) + } +} + +func TestNestedStructVarName(t *testing.T) { + var s Specification + os.Clearenv() + os.Setenv("ENV_CONFIG_REQUIREDVAR", "required") + os.Setenv("ENV_CONFIG_USERS_0", "John1") + val := "found with only short name" + os.Setenv("INNER", val) + if err := Process("env_config", &s); err != nil { + t.Error(err.Error()) + } + if s.NestedSpecification.Property != val { + t.Errorf("expected %s, got %s", val, s.NestedSpecification.Property) + } +} + +func TestTextUnmarshalerError(t *testing.T) { + var s Specification + os.Clearenv() + os.Setenv("ENV_CONFIG_REQUIREDVAR", "foo") + os.Setenv("ENV_CONFIG_DATETIME", "I'M NOT A DATE") + os.Setenv("ENV_CONFIG_USERS_0", "John1") + err := Process("env_config", &s) + + v := &ParseError{} + ok := errors.As(err, &v) + if !ok { + t.Errorf("expected ParseError, got %v", v) + } + if v.FieldName != "Datetime" { + t.Errorf("expected %s, got %v", "Datetime", v.FieldName) + } + + expectedLowLevelError := time.ParseError{ + Layout: time.RFC3339, + Value: "I'M NOT A DATE", + LayoutElem: "2006", + ValueElem: "I'M NOT A DATE", + } + + if v.Err.Error() != expectedLowLevelError.Error() { + t.Errorf("expected %s, got %s", expectedLowLevelError, v.Err) + } +} + +func TestBinaryUnmarshalerError(t *testing.T) { + var s Specification + os.Clearenv() + os.Setenv("ENV_CONFIG_REQUIREDVAR", "foo") + os.Setenv("ENV_CONFIG_URLPOINTER", "http://%41:8080/") + os.Setenv("ENV_CONFIG_USERS_0", "John1") + err := Process("env_config", &s) + + v := &ParseError{} + ok := errors.As(err, &v) + if !ok { + t.Fatalf("expected ParseError, got %T %v", err, err) + } + if v.FieldName != "UrlPointer" { + t.Errorf("expected %s, got %v", "UrlPointer", v.FieldName) + } + + // To be compatible with go 1.5 and lower we should do a very basic check, + // because underlying error message varies in go 1.5 and go 1.6+. + ue := &url.Error{} + ok = errors.As(v.Err, &ue) + if !ok { + t.Errorf("expected error type to be \"*url.Error\", got %T", v.Err) + } + + if ue.Op != "parse" { + t.Errorf("expected error op to be \"parse\", got %q", ue.Op) + } +} + +func TestCheckDisallowedOnlyAllowed(t *testing.T) { + var s Specification + os.Clearenv() + os.Setenv("ENV_CONFIG_DEBUG", "true") + os.Setenv("UNRELATED_ENV_VAR", "true") + err := CheckDisallowed("env_config", &s) + if err != nil { + t.Errorf("expected no error, got %s", err) + } +} + +func TestCheckDisallowedMispelled(t *testing.T) { + var s Specification + os.Clearenv() + os.Setenv("ENV_CONFIG_DEBUG", "true") + os.Setenv("ENV_CONFIG_ZEBUG", "false") + err := CheckDisallowed("env_config", &s) + if experr := "unknown environment variable ENV_CONFIG_ZEBUG"; err.Error() != experr { + t.Errorf("expected %s, got %s", experr, err) + } +} + +func TestCheckDisallowedIgnored(t *testing.T) { + var s Specification + os.Clearenv() + os.Setenv("ENV_CONFIG_DEBUG", "true") + os.Setenv("ENV_CONFIG_IGNORED", "false") + err := CheckDisallowed("env_config", &s) + if experr := "unknown environment variable ENV_CONFIG_IGNORED"; err.Error() != experr { + t.Errorf("expected %s, got %s", experr, err) + } +} + +func TestErrorMessageForRequiredAltVar(t *testing.T) { + var s struct { + Foo string `envconfig:"BAR" required:"true"` + } + + os.Clearenv() + err := Process("env_config", &s) + + if err == nil { + t.Error("no failure when missing required variable") + } + + if !strings.Contains(err.Error(), " BAR ") { + t.Errorf("expected error message to contain BAR, got \"%v\"", err) + } +} + +type bracketed string + +func (b *bracketed) Set(value string) error { + *b = bracketed("[" + value + "]") + return nil +} + +func (b bracketed) String() string { + return string(b) +} + +// quoted is used to test the precedence of Decode over Set. +// The sole field is a flag.Value rather than a setter to validate that +// all flag.Value implementations are also Setter implementations. +type quoted struct{ flag.Value } + +func (d quoted) Decode(value string) error { + return d.Set(`"` + value + `"`) +} + +type setterStruct struct { + Inner string +} + +func (ss *setterStruct) Set(value string) error { + ss.Inner = fmt.Sprintf("setterstruct{%q}", value) + return nil +} + +func BenchmarkGatherInfo(b *testing.B) { + os.Clearenv() + os.Setenv("ENV_CONFIG_DEBUG", "true") + os.Setenv("ENV_CONFIG_PORT", "8080") + os.Setenv("ENV_CONFIG_RATE", "0.5") + os.Setenv("ENV_CONFIG_USER", "Kelsey") + os.Setenv("ENV_CONFIG_TIMEOUT", "2m") + os.Setenv("ENV_CONFIG_ADMINUSERS", "John,Adam,Will") + os.Setenv("ENV_CONFIG_MAGICNUMBERS", "5,10,20") + os.Setenv("ENV_CONFIG_COLORCODES", "red:1,green:2,blue:3") + os.Setenv("SERVICE_HOST", "127.0.0.1") + os.Setenv("ENV_CONFIG_TTL", "30") + os.Setenv("ENV_CONFIG_REQUIREDVAR", "foo") + os.Setenv("ENV_CONFIG_IGNORED", "was-not-ignored") + os.Setenv("ENV_CONFIG_OUTER_INNER", "iamnested") + os.Setenv("ENV_CONFIG_AFTERNESTED", "after") + os.Setenv("ENV_CONFIG_HONOR", "honor") + os.Setenv("ENV_CONFIG_DATETIME", "2016-08-16T18:57:05Z") + os.Setenv("ENV_CONFIG_MULTI_WORD_VAR_WITH_AUTO_SPLIT", "24") + for i := 0; i < b.N; i++ { + var s Specification + gatherInfo("env_config", &s) + } +} diff --git a/toolkit/envconfig/testdata/custom.txt b/toolkit/envconfig/testdata/custom.txt new file mode 100644 index 00000000..04d2f5d0 --- /dev/null +++ b/toolkit/envconfig/testdata/custom.txt @@ -0,0 +1,36 @@ +ENV_CONFIG_ENABLED=some.embedded.value +ENV_CONFIG_EMBEDDEDPORT= +ENV_CONFIG_MULTIWORDVAR= +ENV_CONFIG_MULTI_WITH_DIFFERENT_ALT= +ENV_CONFIG_EMBEDDED_WITH_ALT= +ENV_CONFIG_DEBUG= +ENV_CONFIG_PORT= +ENV_CONFIG_RATE= +ENV_CONFIG_USER= +ENV_CONFIG_TTL= +ENV_CONFIG_TIMEOUT= +ENV_CONFIG_ADMINUSERS= +ENV_CONFIG_MAGICNUMBERS= +ENV_CONFIG_EMPTYNUMBERS= +ENV_CONFIG_BYTESLICE= +ENV_CONFIG_COLORCODES= +ENV_CONFIG_MULTIWORDVAR= +ENV_CONFIG_MULTI_WORD_VAR_WITH_AUTO_SPLIT= +ENV_CONFIG_MULTI_WORD_ACR_WITH_AUTO_SPLIT= +ENV_CONFIG_SOMEPOINTER= +ENV_CONFIG_SOMEPOINTERWITHDEFAULT=foorbar.is.the.word +ENV_CONFIG_MULTI_WORD_VAR_WITH_ALT=what.alt +ENV_CONFIG_MULTI_WORD_VAR_WITH_LOWER_CASE_ALT= +ENV_CONFIG_SERVICE_HOST= +ENV_CONFIG_DEFAULTVAR= +ENV_CONFIG_REQUIREDVAR= +ENV_CONFIG_BROKER= +ENV_CONFIG_REQUIREDDEFAULT= +ENV_CONFIG_OUTER_INNER= +ENV_CONFIG_OUTER_PROPERTYWITHDEFAULT= +ENV_CONFIG_AFTERNESTED= +ENV_CONFIG_HONOR= +ENV_CONFIG_DATETIME= +ENV_CONFIG_MAPFIELD= +ENV_CONFIG_URLVALUE= +ENV_CONFIG_URLPOINTER= diff --git a/toolkit/envconfig/testdata/default_list.txt b/toolkit/envconfig/testdata/default_list.txt new file mode 100644 index 00000000..fb0eced7 --- /dev/null +++ b/toolkit/envconfig/testdata/default_list.txt @@ -0,0 +1,183 @@ +This.application.is.configured.via.the.environment..The.following.environment +variables.can.be.used: + +ENV_CONFIG_ENABLED +..[description].some.embedded.value +..[type]........True.or.False +..[default]..... +..[required].... +ENV_CONFIG_EMBEDDEDPORT +..[description]. +..[type]........Integer +..[default]..... +..[required].... +ENV_CONFIG_MULTIWORDVAR +..[description]. +..[type]........String +..[default]..... +..[required].... +ENV_CONFIG_MULTI_WITH_DIFFERENT_ALT +..[description]. +..[type]........String +..[default]..... +..[required].... +ENV_CONFIG_EMBEDDED_WITH_ALT +..[description]. +..[type]........String +..[default]..... +..[required].... +ENV_CONFIG_DEBUG +..[description]. +..[type]........True.or.False +..[default]..... +..[required].... +ENV_CONFIG_PORT +..[description]. +..[type]........Integer +..[default]..... +..[required].... +ENV_CONFIG_RATE +..[description]. +..[type]........Float +..[default]..... +..[required].... +ENV_CONFIG_USER +..[description]. +..[type]........String +..[default]..... +..[required].... +ENV_CONFIG_TTL +..[description]. +..[type]........Unsigned.Integer +..[default]..... +..[required].... +ENV_CONFIG_TIMEOUT +..[description]. +..[type]........Duration +..[default]..... +..[required].... +ENV_CONFIG_ADMINUSERS +..[description]. +..[type]........Comma-separated.list.of.String +..[default]..... +..[required].... +ENV_CONFIG_MAGICNUMBERS +..[description]. +..[type]........Comma-separated.list.of.Integer +..[default]..... +..[required].... +ENV_CONFIG_EMPTYNUMBERS +..[description]. +..[type]........Comma-separated.list.of.Integer +..[default]..... +..[required].... +ENV_CONFIG_BYTESLICE +..[description]. +..[type]........String +..[default]..... +..[required].... +ENV_CONFIG_COLORCODES +..[description]. +..[type]........Comma-separated.list.of.String:Integer.pairs +..[default]..... +..[required].... +ENV_CONFIG_MULTIWORDVAR +..[description]. +..[type]........String +..[default]..... +..[required].... +ENV_CONFIG_MULTI_WORD_VAR_WITH_AUTO_SPLIT +..[description]. +..[type]........Unsigned.Integer +..[default]..... +..[required].... +ENV_CONFIG_MULTI_WORD_ACR_WITH_AUTO_SPLIT +..[description]. +..[type]........Unsigned.Integer +..[default]..... +..[required].... +ENV_CONFIG_SOMEPOINTER +..[description]. +..[type]........String +..[default]..... +..[required].... +ENV_CONFIG_SOMEPOINTERWITHDEFAULT +..[description].foorbar.is.the.word +..[type]........String +..[default].....foo2baz +..[required].... +ENV_CONFIG_MULTI_WORD_VAR_WITH_ALT +..[description].what.alt +..[type]........String +..[default]..... +..[required].... +ENV_CONFIG_MULTI_WORD_VAR_WITH_LOWER_CASE_ALT +..[description]. +..[type]........String +..[default]..... +..[required].... +ENV_CONFIG_SERVICE_HOST +..[description]. +..[type]........String +..[default]..... +..[required].... +ENV_CONFIG_DEFAULTVAR +..[description]. +..[type]........String +..[default].....foobar +..[required].... +ENV_CONFIG_REQUIREDVAR +..[description]. +..[type]........String +..[default]..... +..[required]....true +ENV_CONFIG_BROKER +..[description]. +..[type]........String +..[default].....127.0.0.1 +..[required].... +ENV_CONFIG_REQUIREDDEFAULT +..[description]. +..[type]........String +..[default].....foo2bar +..[required]....true +ENV_CONFIG_OUTER_INNER +..[description]. +..[type]........String +..[default]..... +..[required].... +ENV_CONFIG_OUTER_PROPERTYWITHDEFAULT +..[description]. +..[type]........String +..[default].....fuzzybydefault +..[required].... +ENV_CONFIG_AFTERNESTED +..[description]. +..[type]........String +..[default]..... +..[required].... +ENV_CONFIG_HONOR +..[description]. +..[type]........HonorDecodeInStruct +..[default]..... +..[required].... +ENV_CONFIG_DATETIME +..[description]. +..[type]........Time +..[default]..... +..[required].... +ENV_CONFIG_MAPFIELD +..[description]. +..[type]........Comma-separated.list.of.String:String.pairs +..[default].....one:two,three:four +..[required].... +ENV_CONFIG_URLVALUE +..[description]. +..[type]........CustomURL +..[default]..... +..[required].... +ENV_CONFIG_URLPOINTER +..[description]. +..[type]........CustomURL +..[default]..... +..[required].... diff --git a/toolkit/envconfig/testdata/default_table.txt b/toolkit/envconfig/testdata/default_table.txt new file mode 100644 index 00000000..65c9b445 --- /dev/null +++ b/toolkit/envconfig/testdata/default_table.txt @@ -0,0 +1,40 @@ +This.application.is.configured.via.the.environment..The.following.environment +variables.can.be.used: + +KEY..............................................TYPE............................................DEFAULT...............REQUIRED....DESCRIPTION +ENV_CONFIG_ENABLED...............................True.or.False.....................................................................some.embedded.value +ENV_CONFIG_EMBEDDEDPORT..........................Integer........................................................................... +ENV_CONFIG_MULTIWORDVAR..........................String............................................................................ +ENV_CONFIG_MULTI_WITH_DIFFERENT_ALT..............String............................................................................ +ENV_CONFIG_EMBEDDED_WITH_ALT.....................String............................................................................ +ENV_CONFIG_DEBUG.................................True.or.False..................................................................... +ENV_CONFIG_PORT..................................Integer........................................................................... +ENV_CONFIG_RATE..................................Float............................................................................. +ENV_CONFIG_USER..................................String............................................................................ +ENV_CONFIG_TTL...................................Unsigned.Integer.................................................................. +ENV_CONFIG_TIMEOUT...............................Duration.......................................................................... +ENV_CONFIG_ADMINUSERS............................Comma-separated.list.of.String.................................................... +ENV_CONFIG_MAGICNUMBERS..........................Comma-separated.list.of.Integer................................................... +ENV_CONFIG_EMPTYNUMBERS..........................Comma-separated.list.of.Integer................................................... +ENV_CONFIG_BYTESLICE.............................String............................................................................ +ENV_CONFIG_COLORCODES............................Comma-separated.list.of.String:Integer.pairs...................................... +ENV_CONFIG_MULTIWORDVAR..........................String............................................................................ +ENV_CONFIG_MULTI_WORD_VAR_WITH_AUTO_SPLIT........Unsigned.Integer.................................................................. +ENV_CONFIG_MULTI_WORD_ACR_WITH_AUTO_SPLIT........Unsigned.Integer.................................................................. +ENV_CONFIG_SOMEPOINTER...........................String............................................................................ +ENV_CONFIG_SOMEPOINTERWITHDEFAULT................String..........................................foo2baz...........................foorbar.is.the.word +ENV_CONFIG_MULTI_WORD_VAR_WITH_ALT...............String............................................................................what.alt +ENV_CONFIG_MULTI_WORD_VAR_WITH_LOWER_CASE_ALT....String............................................................................ +ENV_CONFIG_SERVICE_HOST..........................String............................................................................ +ENV_CONFIG_DEFAULTVAR............................String..........................................foobar............................ +ENV_CONFIG_REQUIREDVAR...........................String................................................................true........ +ENV_CONFIG_BROKER................................String..........................................127.0.0.1......................... +ENV_CONFIG_REQUIREDDEFAULT.......................String..........................................foo2bar...............true........ +ENV_CONFIG_OUTER_INNER...........................String............................................................................ +ENV_CONFIG_OUTER_PROPERTYWITHDEFAULT.............String..........................................fuzzybydefault.................... +ENV_CONFIG_AFTERNESTED...........................String............................................................................ +ENV_CONFIG_HONOR.................................HonorDecodeInStruct............................................................... +ENV_CONFIG_DATETIME..............................Time.............................................................................. +ENV_CONFIG_MAPFIELD..............................Comma-separated.list.of.String:String.pairs.....one:two,three:four................ +ENV_CONFIG_URLVALUE..............................CustomURL......................................................................... +ENV_CONFIG_URLPOINTER............................CustomURL......................................................................... diff --git a/toolkit/envconfig/testdata/fault.txt b/toolkit/envconfig/testdata/fault.txt new file mode 100644 index 00000000..b525ff12 --- /dev/null +++ b/toolkit/envconfig/testdata/fault.txt @@ -0,0 +1,36 @@ +{.Key} +{.Key} +{.Key} +{.Key} +{.Key} +{.Key} +{.Key} +{.Key} +{.Key} +{.Key} +{.Key} +{.Key} +{.Key} +{.Key} +{.Key} +{.Key} +{.Key} +{.Key} +{.Key} +{.Key} +{.Key} +{.Key} +{.Key} +{.Key} +{.Key} +{.Key} +{.Key} +{.Key} +{.Key} +{.Key} +{.Key} +{.Key} +{.Key} +{.Key} +{.Key} +{.Key} diff --git a/toolkit/envconfig/usage.go b/toolkit/envconfig/usage.go new file mode 100644 index 00000000..aebadb03 --- /dev/null +++ b/toolkit/envconfig/usage.go @@ -0,0 +1,164 @@ +// Copyright (c) 2016 Kelsey Hightower and others. All rights reserved. +// Use of this source code is governed by the MIT License that can be found in +// the LICENSE file. + +package envconfig + +import ( + "encoding" + "fmt" + "io" + "os" + "reflect" + "strconv" + "strings" + "text/tabwriter" + "text/template" +) + +const ( + // DefaultListFormat constant to use to display usage in a list format + DefaultListFormat = `This application is configured via the environment. The following environment +variables can be used: +{{range .}} +{{usage_key .}} + [description] {{usage_description .}} + [type] {{usage_type .}} + [default] {{usage_default .}} + [required] {{usage_required .}}{{end}} +` + // DefaultTableFormat constant to use to display usage in a tabular format + DefaultTableFormat = `This application is configured via the environment. The following environment +variables can be used: + +KEY TYPE DEFAULT REQUIRED DESCRIPTION +{{range .}}{{usage_key .}} {{usage_type .}} {{usage_default .}} {{usage_required .}} {{usage_description .}} +{{end}}` +) + +var ( + decoderType = reflect.TypeOf((*Decoder)(nil)).Elem() + setterType = reflect.TypeOf((*Setter)(nil)).Elem() + textUnmarshalerType = reflect.TypeOf((*encoding.TextUnmarshaler)(nil)).Elem() + binaryUnmarshalerType = reflect.TypeOf((*encoding.BinaryUnmarshaler)(nil)).Elem() +) + +func implementsInterface(t reflect.Type) bool { + return t.Implements(decoderType) || + reflect.PtrTo(t).Implements(decoderType) || + t.Implements(setterType) || + reflect.PtrTo(t).Implements(setterType) || + t.Implements(textUnmarshalerType) || + reflect.PtrTo(t).Implements(textUnmarshalerType) || + t.Implements(binaryUnmarshalerType) || + reflect.PtrTo(t).Implements(binaryUnmarshalerType) +} + +// toTypeDescription converts Go types into a human readable description +func toTypeDescription(t reflect.Type) string { + switch t.Kind() { + case reflect.Array, reflect.Slice: + if t.Elem().Kind() == reflect.Uint8 { + return "String" + } + return fmt.Sprintf("Comma-separated list of %s", toTypeDescription(t.Elem())) + case reflect.Map: + return fmt.Sprintf( + "Comma-separated list of %s:%s pairs", + toTypeDescription(t.Key()), + toTypeDescription(t.Elem()), + ) + case reflect.Ptr: + return toTypeDescription(t.Elem()) + case reflect.Struct: + if implementsInterface(t) && t.Name() != "" { + return t.Name() + } + return "" + case reflect.String: + name := t.Name() + if name != "" && name != "string" { + return name + } + return "String" + case reflect.Bool: + name := t.Name() + if name != "" && name != "bool" { + return name + } + return "True or False" + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + name := t.Name() + if name != "" && !strings.HasPrefix(name, "int") { + return name + } + return "Integer" + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + name := t.Name() + if name != "" && !strings.HasPrefix(name, "uint") { + return name + } + return "Unsigned Integer" + case reflect.Float32, reflect.Float64: + name := t.Name() + if name != "" && !strings.HasPrefix(name, "float") { + return name + } + return "Float" + } + return fmt.Sprintf("%+v", t) +} + +// Usage writes usage information to stdout using the default header and table format +func Usage(prefix string, spec interface{}) error { + // The default is to output the usage information as a table + // Create tabwriter instance to support table output + tabs := tabwriter.NewWriter(os.Stdout, 1, 0, 4, ' ', 0) + + err := Usagef(prefix, spec, tabs, DefaultTableFormat) + tabs.Flush() + return err +} + +// Usagef writes usage information to the specified io.Writer using the specified template specification +func Usagef(prefix string, spec interface{}, out io.Writer, format string) error { + + // Specify the default usage template functions + functions := template.FuncMap{ + "usage_key": func(v varInfo) string { return v.Key }, + "usage_description": func(v varInfo) string { return v.Tags.Get("desc") }, + "usage_type": func(v varInfo) string { return toTypeDescription(v.Field.Type()) }, + "usage_default": func(v varInfo) string { return v.Tags.Get("default") }, + "usage_required": func(v varInfo) (string, error) { + req := v.Tags.Get("required") + if req != "" { + reqB, err := strconv.ParseBool(req) + if err != nil { + return "", err + } + if reqB { + req = "true" + } + } + return req, nil + }, + } + + tmpl, err := template.New("envconfig").Funcs(functions).Parse(format) + if err != nil { + return err + } + + return Usaget(prefix, spec, out, tmpl) +} + +// Usaget writes usage information to the specified io.Writer using the specified template +func Usaget(prefix string, spec interface{}, out io.Writer, tmpl *template.Template) error { + // gather first + infos, err := gatherInfo(prefix, spec) + if err != nil { + return err + } + + return tmpl.Execute(out, infos) +} diff --git a/toolkit/envconfig/usage_test.go b/toolkit/envconfig/usage_test.go new file mode 100644 index 00000000..c34b3dc1 --- /dev/null +++ b/toolkit/envconfig/usage_test.go @@ -0,0 +1,155 @@ +// Copyright (c) 2016 Kelsey Hightower and others. All rights reserved. +// Use of this source code is governed by the MIT License that can be found in +// the LICENSE file. + +package envconfig + +import ( + "bytes" + "io" + "io/ioutil" + "log" + "os" + "strings" + "testing" + "text/tabwriter" +) + +var testUsageTableResult, testUsageListResult, testUsageCustomResult, testUsageBadFormatResult string + +func TestMain(m *testing.M) { + + // Load the expected test results from a text file + data, err := ioutil.ReadFile("testdata/default_table.txt") + if err != nil { + log.Fatal(err) + } + testUsageTableResult = string(data) + + data, err = ioutil.ReadFile("testdata/default_list.txt") + if err != nil { + log.Fatal(err) + } + testUsageListResult = string(data) + + data, err = ioutil.ReadFile("testdata/custom.txt") + if err != nil { + log.Fatal(err) + } + testUsageCustomResult = string(data) + + data, err = ioutil.ReadFile("testdata/fault.txt") + if err != nil { + log.Fatal(err) + } + testUsageBadFormatResult = string(data) + + retCode := m.Run() + os.Exit(retCode) +} + +func compareUsage(want, got string, t *testing.T) { + got = strings.ReplaceAll(got, " ", ".") + if want != got { + shortest := len(want) + if len(got) < shortest { + shortest = len(got) + } + if len(want) != len(got) { + t.Errorf("expected result length of %d, found %d", len(want), len(got)) + } + for i := 0; i < shortest; i++ { + if want[i] != got[i] { + t.Errorf("difference at index %d, expected '%c' (%v), found '%c' (%v)\n", + i, want[i], want[i], got[i], got[i]) + break + } + } + t.Errorf("Complete Expected:\n'%s'\nComplete Found:\n'%s'\n", want, got) + } +} + +func TestUsageDefault(t *testing.T) { + var s Specification + os.Clearenv() + save := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + err := Usage("env_config", &s) + outC := make(chan string) + // copy the output in a separate goroutine so printing can't block indefinitely + go func() { + var buf bytes.Buffer + io.Copy(&buf, r) + outC <- buf.String() + }() + w.Close() + os.Stdout = save // restoring the real stdout + out := <-outC + + if err != nil { + t.Error(err.Error()) + } + compareUsage(testUsageTableResult, out, t) +} + +func TestUsageTable(t *testing.T) { + var s Specification + os.Clearenv() + buf := new(bytes.Buffer) + tabs := tabwriter.NewWriter(buf, 1, 0, 4, ' ', 0) + err := Usagef("env_config", &s, tabs, DefaultTableFormat) + tabs.Flush() + if err != nil { + t.Error(err.Error()) + } + compareUsage(testUsageTableResult, buf.String(), t) +} + +func TestUsageList(t *testing.T) { + var s Specification + os.Clearenv() + buf := new(bytes.Buffer) + err := Usagef("env_config", &s, buf, DefaultListFormat) + if err != nil { + t.Error(err.Error()) + } + compareUsage(testUsageListResult, buf.String(), t) +} + +func TestUsageCustomFormat(t *testing.T) { + var s Specification + os.Clearenv() + buf := new(bytes.Buffer) + err := Usagef("env_config", &s, buf, "{{range .}}{{usage_key .}}={{usage_description .}}\n{{end}}") + if err != nil { + t.Error(err.Error()) + } + compareUsage(testUsageCustomResult, buf.String(), t) +} + +func TestUsageUnknownKeyFormat(t *testing.T) { + var s Specification + unknownError := "template: envconfig:1:2: executing \"envconfig\" at <.UnknownKey>" + os.Clearenv() + buf := new(bytes.Buffer) + err := Usagef("env_config", &s, buf, "{{.UnknownKey}}") + if err == nil { + t.Errorf("expected 'unknown key' error, but got no error") + } + if !strings.Contains(err.Error(), unknownError) { + t.Errorf("expected '%s', but got '%s'", unknownError, err.Error()) + } +} + +func TestUsageBadFormat(t *testing.T) { + var s Specification + os.Clearenv() + // If you don't use two {{}} then you get a lieteral + buf := new(bytes.Buffer) + err := Usagef("env_config", &s, buf, "{{range .}}{.Key}\n{{end}}") + if err != nil { + t.Error(err.Error()) + } + compareUsage(testUsageBadFormatResult, buf.String(), t) +} diff --git a/version/version.go b/version/version.go index 748b760a..58b47fae 100644 --- a/version/version.go +++ b/version/version.go @@ -1,3 +1,3 @@ package version -const Release = "v2.2.9" +const Release = "v2.3.0"