Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix gconfig env parsing #17

Merged
merged 4 commits into from
Sep 30, 2023
Merged
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
8 changes: 7 additions & 1 deletion gconfig/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ go get -u github.com/drshriveer/gtools/gconfig
- **Generics:** This library uses generics to fetch configuration values from a yaml file. This works with primitives, slices, maps<sup>†</sup>, and structs (supporting yaml). The same key can be resolved into multiple types. *<sup>†</sup> - note: maps with dimensional keys do not currently work*
- **Internal Type Caching:** After a setting has been parsed into a type it is cached along with that type information for future resolution.
- **Dimensions:** A single configuration file may multiple "dimensions" that are resolved at runtime based on program flags to determine the variation of a setting to vend. Differentiating setting variables by environment/stage (e.g. Development, Beta, Prod) is a great example of how this can be leveraged.
- **Auto-flagging:** The configuration library will automatically turn dimensions into flags! (unless otherwise specified)
- **Auto-flagging:** The configuration library will automatically turn dimensions into flags and parse them! (unless otherwise specified)
- **Env Parsing:** The configuration library will automatically parse dimensions environment variables.
- **GetDimension:** Extract a Dimension value via `gconfig.GetDimension[my.DimensionType](cfg)`.
- **Environmental Overrides:** (TODO) In some cases it is useful to override a single static configuration variable in a specific environment. This can be done though the use of environmental variables.

### Usage
Expand Down Expand Up @@ -90,6 +92,7 @@ func main() {
if err != nil {
panic(err)
}

// ...
}
```
Expand All @@ -98,6 +101,9 @@ func main() {

```go
func DoSomething(cfg *gconfig.Config) {
// Extract the current dimension value (parsed from flags or environment variables).
stage := gconfig.GetDimension[environment.Stage](cfg)

// Fetch individual values:
maxRoutines := gconfig.MustGet[uint64](cfg, "runtime.max-goroutines")
reqTimeout := gconfig.MustGet[time.Duration](cfg, "runtime.request-timeout")
Expand Down
79 changes: 49 additions & 30 deletions gconfig/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import (
"fmt"
"io"
"io/fs"
"os"
"reflect"
"strings"

"gopkg.in/yaml.v3"

Expand All @@ -32,23 +34,17 @@ type dimension struct {
// This is also used to determine the
defaultVal genum.Enum

// flagName is the name of the environment flag to parse when parseEnv is true.
// flagName is the name of the environment flag to parse when parseFlag is true.
flagName string

// parseEnv, if true, will parse the dimension as an environment flag.
parseEnv bool
// parseFlag, if true, will parse the dimension as an environment flag.
parseFlag bool

parsed genum.Enum
}

func (d *dimension) initFlag() error {
d.parsed = d.defaultVal
if !d.parseEnv {
return nil
}

usage := fmt.Sprintf("%s (default=%s): configuration dimension valid options: %s",
d.flagName, d.defaultVal, d.defaultVal.StringValues())

eType := reflect.TypeOf(d.defaultVal)
ptrVal, ok := reflect.New(eType).Interface().(encoding.TextUnmarshaler)
Expand All @@ -58,19 +54,28 @@ func (d *dimension) initFlag() error {
d.defaultVal)
}

if s, ok := lookupEnv(d.flagName); ok {
if err := ptrVal.UnmarshalText([]byte(s)); err != nil {
return err
}
d.parsed, ok = rutils.Unptr(ptrVal).(genum.Enum)
if !ok {
return ErrFailedParsing.Msg(
"environment variable %s=%s found but is not a valid option: %s",
d.parsed, s, d.defaultVal.StringValues())
}
}

// first look for flags that have already been registered..
// if so, this is probably a testing environment, so skip the flag registration.
// long term need to decide if we want to disallow this for safety?
// FIXME: Gavin! test the behavior here... Do we need to tie into the parse function
// in a different way to receive updates if parse is called?
// or is this just all nuts, and that's why I was originally approaching this w/o
// a builder?
// The whole flag part is ... maybe problematic.
// Maybe there's an easier way?
if flag.Lookup(d.flagName) != nil {
if !d.parseFlag || flag.Lookup(d.flagName) != nil {
return nil
}

usage := fmt.Sprintf("%s (default=%s): configuration dimension valid options: %s",
d.flagName, d.defaultVal, d.defaultVal.StringValues())

flag.Func(d.flagName, usage, func(s string) error {
if err := ptrVal.UnmarshalText([]byte(s)); err != nil {
return err
Expand All @@ -95,20 +100,20 @@ func (d *dimension) get() genum.Enum {
// Builder is a configuration builder.
type Builder struct {
// An ordered set of dimensions to switch a configuration on.
dimensions []dimension
dimensions []*dimension
}

// NewBuilder returns a new builder instance.
func NewBuilder() *Builder {
return &Builder{}
}

// WithDimension adds a new dimension to switch configurations on. By default `parseEnv` will be true when using this method.
// WithDimension adds a new dimension to switch configurations on. By default `parseFlag` will be true when using this method.
func (b *Builder) WithDimension(name string, defaultVal genum.Enum) *Builder {
d := dimension{
d := &dimension{
defaultVal: defaultVal,
flagName: name,
parseEnv: true,
parseFlag: true,
parsed: defaultVal,
}
if err := d.initFlag(); err != nil {
Expand Down Expand Up @@ -173,7 +178,7 @@ func (b *Builder) FromBytes(bytes []byte) (*Config, error) {
// . the parser/builder step could convert the differenced enums to parsable characters.
// . then

func reduceAny(in any, dimensions []dimension, dIndex int) (any, error) {
func reduceAny(in any, dimensions []*dimension, dIndex int) (any, error) {
switch v := in.(type) {
case map[string]any:
for i := dIndex; i < len(dimensions); i++ {
Expand All @@ -197,15 +202,15 @@ func reduceAny(in any, dimensions []dimension, dIndex int) (any, error) {
return in, nil
}

func reduce(in map[string]any, dimensions []dimension, dIndex int) (any, error) {
func reduce(in map[string]any, dimensions []*dimension, dIndex int) (any, error) {
if dIndex+1 > len(dimensions) {
return in, nil
}
dimension := dimensions[dIndex]
// check if this a valid dimension to reduce.
dim := dimensions[dIndex]
// check if this a valid dim to reduce.
// if it is, grab the correct one and reduce the rest.
keys, hasDefault := keySet(in)
keys.Remove(dimension.defaultVal.StringValues()...)
keys.Remove(dim.defaultVal.StringValues()...)
if len(keys) != 0 {
for k, v := range in {
var err error
Expand All @@ -214,12 +219,12 @@ func reduce(in map[string]any, dimensions []dimension, dIndex int) (any, error)
return nil, err
}
}
// NOT reducable with this dimension. need to try next,
// NOT reducable with this dim. need to try next,
return in, nil
}
// otherwise this is reducable.
// case 1: we have the dimension's key. Simply follow it.
if v, ok := in[dimension.parsed.String()]; ok {
// case 1: we have the dim's key. Simply follow it.
if v, ok := in[dim.get().String()]; ok {
return reduceAny(v, dimensions, dIndex+1)
}
// case 2: we have default
Expand All @@ -234,8 +239,8 @@ func reduce(in map[string]any, dimensions []dimension, dIndex int) (any, error)
// ...going with #1.
keys, _ = keySet(in)
return nil, ErrFailedParsing.Msg(
"broken dimension key! %T dimensions identified around keys %s, but no `default` or `%s` value found.",
dimension.defaultVal, keys.Slice(), dimension.parsed)
"broken dim key! %T dimensions identified around keys %s, but no `default` or `%s` value found.",
dim.defaultVal, keys.Slice(), dim.get())
}

func keySet(in map[string]any) (set.Set[string], bool) {
Expand All @@ -250,3 +255,17 @@ func keySet(in map[string]any) (set.Set[string], bool) {
}
return result, hasDefault
}

// lookupEnv looks for an environment variable in case sensitive, upper, and lower case forms.
func lookupEnv(key string) (string, bool) {
if s, ok := os.LookupEnv(key); ok {
return s, ok
}
if s, ok := os.LookupEnv(strings.ToUpper(key)); ok {
return s, ok
}
if s, ok := os.LookupEnv(strings.ToLower(key)); ok {
return s, ok
}
return "", false
}
32 changes: 32 additions & 0 deletions gconfig/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package gconfig_test

import (
"embed"
"flag"
"fmt"
"os"
"testing"
"time"

Expand All @@ -25,6 +27,36 @@ type testStruct struct {
Name string `yaml:"name"`
}

func TestFlagParsing(t *testing.T) {
builder := gconfig.NewBuilder().
WithDimension("d1", internal.D1a).
WithDimension("d2", internal.D2a)

require.NoError(t, flag.Set("d1", internal.D1c.String()))
require.NoError(t, flag.Set("d2", internal.D2e.String()))
cfg, err := builder.FromFile(testFS, "internal/test.yaml")
require.NoError(t, err)
assert.Equal(t, internal.D1c, gconfig.GetDimension[internal.DimensionOne](cfg))
assert.Equal(t, internal.D2e, gconfig.GetDimension[internal.DimensionTwo](cfg))
}

func TestEnvParsing(t *testing.T) {
require.NoError(t, os.Setenv("D1", internal.D1c.String()))
require.NoError(t, os.Setenv("d2", internal.D2e.String()))
t.Cleanup(func() {
require.NoError(t, os.Unsetenv("D1"))
require.NoError(t, os.Unsetenv("d2"))
})
cfg, err := gconfig.NewBuilder().
WithDimension("d1", internal.D1a).
WithDimension("d2", internal.D2a).
FromFile(testFS, "internal/test.yaml")

require.NoError(t, err)
assert.Equal(t, internal.D1c, gconfig.GetDimension[internal.DimensionOne](cfg))
assert.Equal(t, internal.D2e, gconfig.GetDimension[internal.DimensionTwo](cfg))
}

func TestDimensions(t *testing.T) {
standardStructDefault := testStruct{
Pi: 3,
Expand Down
Loading