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

Config #4

Merged
merged 13 commits into from
May 26, 2021
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Wharf core library changelog

This project tries to follow [SemVer 2.0.0](https://semver.org/).

<!--
When composing new changes to this list, try to follow convention.
The WIP release shall be updated just before adding the Git tag.
From (WIP) to (YYYY-MM-DD), ex: (2021-02-09) for 9th of Febuary, 2021
A good source on conventions can be found here:
https://changelog.md/
-->

## v1.0.0 (WIP)

- Added `pkg/config` with newly added `config.Config` interface and
implementation to let you load configs from environment variables or YAML
files. This is done via [spf13/viper](https://github.com/spf13/viper). (#4)
8 changes: 8 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
module github.com/iver-wharf/wharf-core

go 1.16

require (
github.com/spf13/viper v1.7.1
gopkg.in/yaml.v2 v2.4.0
)
302 changes: 302 additions & 0 deletions go.sum

Large diffs are not rendered by default.

199 changes: 199 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
package config

import (
"bytes"
"errors"
"fmt"
"io"
"io/fs"
"strings"

"github.com/spf13/viper"
"gopkg.in/yaml.v2"
)

const configTypeYAML = "yaml"

// Config type has methods for registering configuration sources, and then using
// those sources you can unmarshal into a struct to read the configuration.
//
// Later added config sources will merge on top of the previous on a
// per config field basis. Later added sources will override earlier added
// sources.
type Config interface {
// AddConfigYAMLFile appends the path of a YAML file to the list of sources
// for this configuration.
//
// Later added config sources will merge on top of the previous on a
// per config field basis. Later added sources will override earlier added
// sources.
AddConfigYAMLFile(path string)

// AddConfigYAML appends a byte reader for UTF-8 and YAML formatted content.
// Useful for reading from embedded files, database stored configs, and from
// HTTP response bodies.
//
// Later added config sources will merge on top of the previous on a
// per config field basis. Later added sources will override earlier added
// sources.
AddConfigYAML(reader io.Reader)

// AddEnvironmentVariables appends an environment variable source.
//
// Later added config sources will merge on top of the previous on a
// per config field basis. Later added sources will override earlier added
// sources.
//
// However, multiple environment variable sources cannot be added, due to
// technical limitations with the implementation. Even if they use different
// prefixes.
//
// Environment variables must be in all uppercase letters, and nested
// structs use a single underscore "_" as delimiter. Example:
//
// c := config.New(myConfigDefaults)
// c.AddEnvironmentVariables("FOO")
//
// type MyConfig struct {
// Bar string // set via "FOO_BAR"
// LoremIpsum string // set via "FOO_LOREMIPSUM"
// Hello struct {
// World string // set via "FOO_HELLO_WORLD"
// }
// }
//
// The prefix shall be without a trailing underscore "_" as this package
// adds that in by itself. To not use a prefix, pass in an empty string as
// prefix instead.
AddEnvironmentVariables(prefix string)

// Unmarshal applies the configuration, based on the numerous added sources,
// on to an existing struct.
//
// For any field of type pointer and is set to nil, this function will
// create a new instance and assign that before populating that branch.
//
// If none of the Config.Add...() functions has been called before this
// function, then this function will effectively only apply the default
// configuration onto this new object.
//
// The error that is returned is caused by any of the added config sources,
// such as from invalid YAML syntax in an added YAML file.
Unmarshal(config interface{}) error
}

// New creates a new Config based on a default configuration.
//
// Due to technical limitations, it's vital that this default configuration is
// of the same type that the config that you wish to unmarshal later, or at
// least that it contains fields with the same names.
func New(defaultConfig interface{}) Config {
return &config{
defaultConfig: defaultConfig,
}
}

type config struct {
defaultConfig interface{}
sources []configSource
}

type configSource interface {
name() string
apply(v *viper.Viper) error
}

func (c *config) AddConfigYAMLFile(path string) {
c.sources = append(c.sources, yamlFileSource{path})
}

func (c *config) AddConfigYAML(reader io.Reader) {
c.sources = append(c.sources, yamlSource{reader})
}

func (c *config) AddEnvironmentVariables(prefix string) {
c.sources = append(c.sources, envVarsSource{prefix})
}

func (c *config) Unmarshal(config interface{}) error {
v := viper.New()
initDefaults(v, c.defaultConfig)
for _, s := range c.sources {
if err := s.apply(v); err != nil {
return fmt.Errorf("applying config source: %s: %T: %w", s.name(), err, err)
}
}
return v.Unmarshal(config)
}

func initDefaults(v *viper.Viper, defaultConfig interface{}) error {
// Uses a workaround to force viper to read environment variables
// by making it aware of all fields that exists so it can later map
// environment variables correctly.
// https://github.com/spf13/viper/issues/188#issuecomment-413368673
b, err := yaml.Marshal(defaultConfig)
if err != nil {
return fmt.Errorf("setting config defaults: %w", err)
}
defaultCfg := bytes.NewReader(b)
v.SetConfigType(configTypeYAML)
if err := v.MergeConfig(defaultCfg); err != nil {
return fmt.Errorf("setting config defaults: %w", err)
}
return nil
}

type yamlFileSource struct {
path string
}

func (s yamlFileSource) name() string {
return s.path
}

func (s yamlFileSource) apply(v *viper.Viper) error {
if s.path == "" {
// viper does not set config file if its empty, so viper.MergeInConfig()
// would then use the previously set config path value
return nil
}
v.SetConfigType(configTypeYAML)
v.SetConfigFile(s.path)
err := v.MergeInConfig()
// ignore not-found errors
if errors.Is(err, fs.ErrNotExist) {
return nil
}
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
return nil
}
return err
}

type yamlSource struct {
reader io.Reader
}

func (s yamlSource) name() string {
return "YAML io.Reader"
}

func (s yamlSource) apply(v *viper.Viper) error {
v.SetConfigType(configTypeYAML)
return v.MergeConfig(s.reader)
}

type envVarsSource struct {
prefix string
}

func (s envVarsSource) name() string {
return "environment variables"
}

func (s envVarsSource) apply(v *viper.Viper) error {
v.SetEnvPrefix(s.prefix)
v.AutomaticEnv()
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
return nil
}
166 changes: 166 additions & 0 deletions pkg/config/config_example_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package config_test

import (
"bytes"
_ "embed"
"fmt"
"os"
"strings"

"github.com/iver-wharf/wharf-core/pkg/config"
)

type Logging struct {
LogLevel string
}

type DBConfig struct {
Host string
Port int
}

type Config struct {
// Both mapstructure:",squash" and yaml:",inline" needs to be set in
// embedded structs, even if you're only reading environment variables
Logging `mapstructure:",squash" yaml:",inline"`
Username string
Password string
DB DBConfig
}

var defaultConfig = Config{
Logging: Logging{
LogLevel: "Warning",
},
Username: "postgres",
DB: DBConfig{
Host: "localhost",
Port: 5432,
},
}

//go:embed testdata/embedded-config.yml
var embeddedConfig []byte

func ExampleConfig() {
c := config.New(defaultConfig)
c.AddConfigYAML(bytes.NewReader(embeddedConfig))
c.AddConfigYAMLFile("/etc/my-app/config.yml")
c.AddConfigYAMLFile("$HOME/.config/my-app/config.yml")
c.AddConfigYAMLFile("my-app-config.yml") // from working directory
c.AddEnvironmentVariables("MYAPP")

var config Config
if err := c.Unmarshal(&config); err != nil {
fmt.Println("Failed to read config:", err)
return
}

fmt.Println("Log level:", config.LogLevel)
fmt.Println("Username: ", config.Username)
fmt.Println("Password: ", config.Password)
fmt.Println("DB host: ", config.DB.Host)
fmt.Println("DB port: ", config.DB.Port)

// Output:
// Log level: Info
// Username: postgres
// Password: Sommar2020
// DB host: localhost
// DB port: 8080
}

func ExampleConfig_AddEnvironmentVariables() {
c := config.New(defaultConfig)
c.AddEnvironmentVariables("")

// Environment variables can, but should not, be set like this.
// Recommended to set them externally instead.
os.Setenv("DB_PORT", "8080")
os.Setenv("PASSWORD", "Sommar2020")
os.Setenv("LOGLEVEL", "Info")
// Environment variables must be all uppercase
os.Setenv("Username", "not used")

var config Config
if err := c.Unmarshal(&config); err != nil {
fmt.Println("Failed to read config:", err)
return
}

fmt.Println("Log level:", config.LogLevel)
fmt.Println("Username: ", config.Username)
fmt.Println("Password: ", config.Password)
fmt.Println("DB host: ", config.DB.Host)
fmt.Println("DB port: ", config.DB.Port)

// Output:
// Log level: Info
// Username: postgres
// Password: Sommar2020
// DB host: localhost
// DB port: 8080
}

func ExampleConfig_AddConfigYAML() {
// This content could come from go:embed or a HTTP response body
yamlBytes := `
logLevel: Info
# YAML key names are case-insensitive
pAssWOrD: Sommar2020
db:
port: 8080
`
c := config.New(defaultConfig)
c.AddConfigYAML(strings.NewReader(yamlBytes))

var config Config
if err := c.Unmarshal(&config); err != nil {
fmt.Println("Failed to read config:", err)
return
}

fmt.Println("Log level:", config.LogLevel)
fmt.Println("Username: ", config.Username)
fmt.Println("Password: ", config.Password)
fmt.Println("DB host: ", config.DB.Host)
fmt.Println("DB port: ", config.DB.Port)

// Output:
// Log level: Info
// Username: postgres
// Password: Sommar2020
// DB host: localhost
// DB port: 8080
}

func ExampleConfig_AddConfigYAMLFile() {
c := config.New(defaultConfig)
// The file referenced here contains the following:
//
// logLevel: Info
// # YAML key names are case-insensitive
// pAssWOrD: Sommar2020
// db:
// port: 8080
c.AddConfigYAMLFile("testdata/add-config-yaml-file.yml")

var config Config
if err := c.Unmarshal(&config); err != nil {
fmt.Println("Failed to read config:", err)
return
}

fmt.Println("Log level:", config.LogLevel)
fmt.Println("Username: ", config.Username)
fmt.Println("Password: ", config.Password)
fmt.Println("DB host: ", config.DB.Host)
fmt.Println("DB port: ", config.DB.Port)

// Output:
// Log level: Info
// Username: postgres
// Password: Sommar2020
// DB host: localhost
// DB port: 8080
}
4 changes: 4 additions & 0 deletions pkg/config/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Package config helps you with reading configuration from files and
// environment variables in a unified way. This package is used throughout Wharf
// to read configurations.
package config
Loading