Skip to content

Commit

Permalink
Refactored UI to allow for default values and value overrides. Also g…
Browse files Browse the repository at this point in the history
…et better error messages for missing env values
  • Loading branch information
cinemast committed Sep 28, 2024
1 parent 5ecfe10 commit f8194bf
Show file tree
Hide file tree
Showing 4 changed files with 88 additions and 50 deletions.
3 changes: 2 additions & 1 deletion .env.sample
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
MY_APP_EMAIL=[email protected]
#MY_APP_PASSWORD=password
MY_APP_PASSWORD=password2
MY_APP_ENDPOINT=some-endpoint
MY_APP_ENDPOINT=some-endpoint
MY_APP_URL=https://some.exmpale.org/foo?token=bar
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,21 @@ A minimalistic approach to [spf13/viper](https://github.com/spf13/viper).
## Features
- Read `ENV` variables into a `struct`
- Read a `.env` file into a `struct`
- `< 100` source lines of code
- `< 110` source lines of code
- [No dependencies](go.mod)

Only string fields are supported.

## Usage

```go
package main

import (
"github.com/nobloat/tinyviper"
"fmt"
)

type Config struct {
UserConfig struct {
Email string `env:"MY_APP_EMAIL"`
Expand All @@ -25,8 +32,8 @@ type Config struct {
}

func main() {
//cfg, err := NewEnvConfig[Config]() //Read from env
cfg, err := NewEnvFileConfig[Config](".env.sample") //Read from .env file
cfg := Config{}
cfg, err := tinyviper.LoadFromResolver(&cfg, tinyviper.EnvResolver{}, tinyviper.NewEnvFileResolver(".env.sample"))
if err != nil {
panic(err)
}
Expand Down
86 changes: 45 additions & 41 deletions tinyviper.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,67 +11,69 @@ import (
type Resolver interface {
Get(key string) string
}

type EnvResolver struct{}

type EnvFileResolver struct {
Variables map[string]string
}

func NewEnvFileResolver(filename string) (*EnvFileResolver, error) {
func (e EnvFileResolver) Get(key string) string {
return e.Variables[key]
}
func (e EnvResolver) Get(key string) string {
return os.Getenv(key)
}

type multiResolver struct {
resolvers []Resolver
}

func NewEnvFileResolver(filename string) *EnvFileResolver {
readFile, err := os.Open(filename)
if err != nil {
return nil, err
return nil
}
defer readFile.Close()
r := &EnvFileResolver{make(map[string]string, 0)}
fileScanner := bufio.NewScanner(readFile)
fileScanner.Split(bufio.ScanLines)
for fileScanner.Scan() {
text := fileScanner.Text()
parts := strings.Split(text, "=")
if len(parts) != 2 || strings.HasPrefix(text, "#") {
index := strings.Index(text, "=")
if index == -1 || strings.HasPrefix(text, "#") {
continue
}
r.Variables[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
r.Variables[text[0:index]] = strings.Trim(text[index+1:], " \"'")
}
return r, readFile.Close()
}

func (e EnvFileResolver) Get(key string) string {
return e.Variables[key]
}

func (e EnvResolver) Get(key string) string {
return os.Getenv(key)
return r
}

func NewEnvConfig[T any]() (*T, error) {
cfg := new(T)
res := EnvResolver{}
err := ReflectStruct(cfg, res)
if err != nil {
return nil, err
func (m multiResolver) Get(key string) string {
for _, r := range m.resolvers {
v := r.Get(key)
if r.Get(key) != "" {
return v
}
}
return cfg, nil
return ""
}

func NewEnvFileConfig[T any](filename string) (*T, error) {
cfg := new(T)
res, err := NewEnvFileResolver(filename)
func LoadFromResolver[T any](cfg *T, resolver ...Resolver) error {
res := multiResolver{resolvers: resolver}
missing := make([]string, 0)
missing, err := refelectStruct(cfg, res, missing)
if err != nil {
return nil, err
return err
}
err = ReflectStruct(cfg, res)
if err != nil {
return nil, err
if len(missing) > 0 {
return errors.New("missing config variables: " + strings.Join(missing, ","))
}
return cfg, nil
return nil
}

func ReflectStruct(object any, resolver Resolver) error {
func refelectStruct(object any, resolver Resolver, missing []string) ([]string, error) {
v := reflect.ValueOf(object)
if v.Elem().Kind() != reflect.Struct {
return errors.New("type must be a struct")
return missing, errors.New("type must be a struct")
}
e := v.Elem()
t := e.Type()
Expand All @@ -81,22 +83,24 @@ func ReflectStruct(object any, resolver Resolver) error {
envName := tf.Tag.Get("env")
if envName != "" {
if tf.Type != reflect.TypeOf("") {
return errors.New("env annotated field must have type string")
return missing, errors.New("env annotated field must have type string")
}
if !ef.CanSet() {
return errors.New("env field must be public")
return missing, errors.New("env field must be public")
}
value := resolver.Get(envName)
if value == "" {
return errors.New("env variable " + envName + " is not set")
if value != "" {
ef.SetString(resolver.Get(envName))
} else if ef.String() == "" {
missing = append(missing, envName)
}
ef.SetString(resolver.Get(envName))
} else if t.Kind() == reflect.Struct {
err := ReflectStruct(ef.Addr().Interface(), resolver)
m, err := refelectStruct(ef.Addr().Interface(), resolver, missing)
if err != nil {
return err
return missing, err
}
missing = append(missing, m...)
}
}
return nil
return missing, nil
}
36 changes: 31 additions & 5 deletions tinyviper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,49 @@ type Config struct {
Password string `env:"MY_APP_PASSWORD"`
}
Endpoint string `env:"MY_APP_ENDPOINT"`
AppUrl string `env:"MY_APP_URL"`
}

func TestConfig(t *testing.T) {
cfg, err := NewEnvFileConfig[Config](".env.sample")
type testEnvResolver struct{}

func (t testEnvResolver) Get(key string) string {
switch key {
case "MY_APP_EMAIL":
return "[email protected]"
case "MY_APP_PASSWORD":
return "[email protected]"
default:
return ""
}
}

func TestConfigErrors(t *testing.T) {
cfg := Config{}
err := LoadFromResolver(&cfg, testEnvResolver{})
if err == nil {
t.Fatalf("Expected error, got none")
}
if err.Error() != "missing config variables: MY_APP_ENDPOINT,MY_APP_URL" {
t.Error("Expected error, got wrong one: " + err.Error())
}
}

func TestConfigNew(t *testing.T) {
cfg := Config{}
err := LoadFromResolver(&cfg, EnvResolver{}, NewEnvFileResolver(".env.sample"))
if err != nil {
t.Error(err)
}

if cfg.UserConfig.Email != "[email protected]" {
t.Error(errors.New("unexpected email"))
}

if cfg.UserConfig.Password != "password2" {
t.Error(errors.New("unexpected password"))
}

if cfg.Endpoint != "some-endpoint" {
t.Error(errors.New("unexpected endpoint"))
}
if cfg.AppUrl != "https://some.exmpale.org/foo?token=bar" {
t.Error(errors.New("unexpected url"))
}
}

0 comments on commit f8194bf

Please sign in to comment.