Skip to content

Commit

Permalink
Add file and env providers
Browse files Browse the repository at this point in the history
  • Loading branch information
Milad Abbasi committed Jan 1, 2021
1 parent 646bfdf commit b8aca6b
Show file tree
Hide file tree
Showing 11 changed files with 913 additions and 357 deletions.
3 changes: 0 additions & 3 deletions .gitignore
Expand Up @@ -19,6 +19,3 @@
# vendor

.DS_Store

.env*
!.env.example
184 changes: 184 additions & 0 deletions env.go
@@ -0,0 +1,184 @@
package gonfig

import (
"errors"
"os"
"strings"

"github.com/joho/godotenv"
)

// EnvProvider loads values from environment variables to provided struct
type EnvProvider struct {
// Prefix is used when finding values from environment variables, defaults to ""
EnvPrefix string

// SnakeCase specifies whether to convert field names to snake_case or not, defaults to true
SnakeCase bool

// UpperCase specifies whether to convert field names to UPPERCASE or not, defaults to true
UpperCase bool

// FieldSeparator is used to separate field names, defaults to "_"
FieldSeparator string

// Source is used to retrieve environment variables
// It can be either a path to a file or empty string, if empty OS will be used
Source string

// Whether to report error if env file is not found, defaults to false
Required bool
}

// NewEnvProvider creates a new EnvProvider
func NewEnvProvider() *EnvProvider {
return &EnvProvider{
EnvPrefix: "",
SnakeCase: true,
UpperCase: true,
FieldSeparator: "_",
Source: "",
Required: false,
}
}

// NewEnvFileProvider creates a new EnvProvider from .env file
func NewEnvFileProvider(path string) *EnvProvider {
return &EnvProvider{
EnvPrefix: "",
SnakeCase: true,
UpperCase: true,
FieldSeparator: "_",
Source: path,
Required: false,
}
}

// Name of provider
func (ep *EnvProvider) Name() string {
return "ENV provider"
}

// Fill takes struct fields and fills their values
func (ep *EnvProvider) Fill(in *Input) error {
content, err := ep.envMap()
if err != nil {
return err
}

for _, f := range in.Fields {
value, err := ep.provide(content, f.Tags.Config, f.Path)
if err != nil {
if errors.Is(err, ErrKeyNotFound) {
continue
}

return err
}

err = in.setValue(f, value)
if err != nil {
return err
}

f.IsSet = true
}

return nil
}

// envMap returns environment variables map from either OS or file specified by source
// Defaults to operating system env variables
func (ep *EnvProvider) envMap() (map[string]string, error) {
envs := envFromOS()
var fileEnvs map[string]string
var err error

if ep.Source != "" {
fileEnvs, err = envFromFile(ep.Source)
}
if err != nil {
notExistsErr := errors.Is(err, os.ErrNotExist)
if (notExistsErr && ep.Required) || !notExistsErr {
return nil, err
}
}

if len(envs) == 0 {
if len(fileEnvs) == 0 {
return nil, nil
}

envs = make(map[string]string)
}

for k, v := range fileEnvs {
_, exists := envs[k]
if !exists {
envs[k] = v
}
}

return envs, nil
}

// returns environment variables map retrieved from operating system
func envFromOS() map[string]string {
envs := os.Environ()
if len(envs) == 0 {
return nil
}

envMap := make(map[string]string)

for _, env := range envs {
keyValue := strings.SplitN(env, "=", 2)
if len(keyValue) < 2 {
continue
}

envMap[keyValue[0]] = keyValue[1]
}

return envMap
}

// returns environment variables map retrieved from specified file
func envFromFile(path string) (map[string]string, error) {
m, err := godotenv.Read(path)
if err != nil {
return nil, err
}

return m, nil
}

// provide find a value from env variables based on specified key and path
func (ep *EnvProvider) provide(content map[string]string, key string, path []string) (string, error) {
k := ep.buildKey(key, path)
value, exists := content[k]
if !exists {
return "", ErrKeyNotFound
}

return value, nil
}

// buildKey prefix key with EnvPrefix, if not provided, path slice will be used
func (ep *EnvProvider) buildKey(key string, path []string) string {
if key != "" {
return ep.EnvPrefix + key
}

k := strings.Join(path, ep.FieldSeparator)
if ep.SnakeCase {
k = toSnakeCase(k)
}
if ep.UpperCase {
k = strings.ToUpper(k)
}

k = ep.EnvPrefix + k

return k
}
12 changes: 10 additions & 2 deletions errors.go
Expand Up @@ -10,8 +10,14 @@ var (
// Can not handle specified type
ErrUnsupportedType = errors.New("unsupported type")

// Only ".json", ".yml", ".yaml" and ".env" file types are supported
ErrUnsupportedFileExt = errors.New("unsupported file extension")

// Provider could not find value with specified key
ErrKeyNotFound = errors.New("key not found")

// Field is required but no value provided
ErrMissingValue = errors.New("missing value")
ErrRequiredField = errors.New("field is required")

// Could not parse the string value
ErrParsing = errors.New("failed parsing")
Expand All @@ -21,8 +27,10 @@ var (
)

const (
missingValueErrFormat = `%w: "%v" is required`
unsupportedTypeErrFormat = `%w: cannot handle type "%v" at "%v"`
unsupportedFileExtErrFormat = `%w: %v`
decodeFailedErrFormat = `failed to decode: %w`
requiredFieldErrFormat = `%w: no value found for "%v"`
unsupportedElementTypeErrFormat = `%w: cannot handle slice/array of "%v" at "%v"`
parseErrFormat = `%w at "%v": %v`
overflowErrFormat = `%w: "%v" overflows type "%v" at "%v"`
Expand Down
137 changes: 137 additions & 0 deletions file.go
@@ -0,0 +1,137 @@
package gonfig

import (
"encoding/json"
"fmt"
"os"
"path/filepath"

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

// Supported file extensions
const (
JSON = ".json"
YML = ".yml"
YAML = ".yaml"
ENV = ".env"
TOML = ".toml"
)

// FileProvider loads values from file to provided struct
type FileProvider struct {
// Path to file
FilePath string

// File will be decoded based on extension
// .json, .yml(.yaml), .env and .toml file extensions are supported
FileExt string

// Whether to report error if file is not found, defaults to false
Required bool
}

// NewFileProvider creates a new FileProvider from specified path
func NewFileProvider(path string) *FileProvider {
return &FileProvider{
FilePath: path,
FileExt: filepath.Ext(path),
Required: false,
}
}

// Name of provider
func (fp *FileProvider) Name() string {
return "File provider"
}

// UnmarshalStruct takes a struct pointer and loads values from provided file into it
func (fp *FileProvider) UnmarshalStruct(i interface{}) error {
return fp.decode(i)
}

// Fill takes struct fields and and checks if their value is set
func (fp *FileProvider) Fill(in *Input) error {
var content map[string]interface{}
if err := fp.decode(&content); err != nil {
return err
}

for _, f := range in.Fields {
if f.IsSet {
continue
}

var key string
switch fp.FileExt {
case JSON:
key = f.Tags.Json
case YML, YAML:
key = f.Tags.Yaml
case TOML:
key = f.Tags.Toml
}

_, err := fp.provide(content, key, f.Path)
if err == nil {
f.IsSet = true
}
}

return nil
}

// decode opens specified file and loads its content to input argument
func (fp *FileProvider) decode(i interface{}) (err error) {
f, err := os.Open(fp.FilePath)
if err != nil {
if os.IsNotExist(err) && !fp.Required {
return nil
}

return fmt.Errorf("file provider: %w", err)
}
defer func() {
if cerr := f.Close(); cerr != nil && err == nil {
err = cerr
}
}()

switch fp.FileExt {
case JSON:
err = json.NewDecoder(f).Decode(i)

case YML, YAML:
err = yaml.NewDecoder(f).Decode(i)

case TOML:
_, err = toml.DecodeReader(f, i)

default:
err = fmt.Errorf(unsupportedFileExtErrFormat, ErrUnsupportedFileExt, fp.FileExt)
}

if err != nil {
return fmt.Errorf(decodeFailedErrFormat, err)
}

return nil
}

// provide find a value from file content based on specified key and path
func (fp *FileProvider) provide(content map[string]interface{}, key string, path []string) (string, error) {
return traverseMap(content, fp.buildPath(key, path))
}

// buildPath makes a path from key and path slice
func (fp *FileProvider) buildPath(key string, path []string) []string {
newPath := make([]string, len(path))
copy(newPath, path)

if key != "" {
newPath[len(newPath)-1] = key
}

return newPath
}
6 changes: 6 additions & 0 deletions go.mod
@@ -1,3 +1,9 @@
module github.com/milad-abbasi/gonfig

go 1.15

require (
github.com/BurntSushi/toml v0.3.1
github.com/joho/godotenv v1.3.0
gopkg.in/yaml.v2 v2.4.0
)
7 changes: 7 additions & 0 deletions go.sum
@@ -0,0 +1,7 @@
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=

0 comments on commit b8aca6b

Please sign in to comment.