Skip to content

asecurityteam/settings

Repository files navigation

Settings - Typed Configuration Toolkit For Go

GoDoc

Status: Incubation

Overview

There aren't very many well tested and maintained libraries in the ecosystem for managing configuration values in a go application. The two that appear with the most prevalence when searching are viper and go-config. Both of these projects provide both dynamic configuration reloading and a broad interface for fetching and converting configuration values. Both of those libraries are fairly stable and excellent choices for configuration if they fit your needs.

This project grew out of a desire for a configuration system that:

  • Allows for the minimum amount of coupling between components needing configuration and the configuration system.

  • Focuses on enabling plugin, or at least swappable component, based systems.

  • Enables developers to define configuration as standard go types rather than as strings in a global, opaque registrar.

  • Provides the ability to remix the basic configuration components to create new complex systems that may differ from our initial assumptions about how we need configuration to work in a given system.

  • Generates useful help output and example configurations from a given set of options.

Honestly, [viper] and [go-config] both come close to satisfying most of these wants but fall short of what we needed in small, but consequential, ways. This project attempts to overcome those small deficits by offering:

  • An extremely minimal interface for defining sources of configuration data so that new or proprietary data sources can be more easily added.

  • A high level API for working with loosely coupled components that describe their configurations with standard go types.

  • A mid level API for defining and managing configuration hierarchies.

  • A low level API for interfacing between statically typed options and weakly typed configuration sources.

Data Sources

All forms of our API interact in some with a source of configuration data. The interface for a source is:

type Source interface {
    Get(ctx context.Context, path []string) (interface{}, error)
}

It is intentionally simple and leaves every implementation detail to the the team creating a new source. Packaged with this project are Source implementations for JSON, YAML, and ENV. We also provide a minimal set of tools for arranging and composing data sources:

jsonSource, _ := settings.NewFileSource("config.json")
yamlSource, _ := settings.NewFileSource("config.yaml")
envSource := settings.NewEnvSource(os.Environ())
// Apply a static prefix to all env lookups. For example,
// here we add a APP_ to all names.
prefixedEnv := &settings.PrefixSource{
    Source: envSource,
    Prefix: []string{"APP"},
}
// Create an ordered lookup where ENV is first with a fallback to
// YAML which can fallback to JSON.  The first found is used.
finalSource := []settings.MultiSource{prefixedEnv, yamlSource, jsonSource}

v, found := finalSource.Get(context.Background(), "setting")

The built-in sources, including MultiSource, support variable substitution, so that you can effectively perform variable mapping. For example, in the following, the final value of B_BB key will be envValue.

config.yaml (be sure to wrap reference values in quotes so the yaml parser interprets it as a string)

b:
  bb: "${A_AA}"

Environment Variable

A_AA="envValue"
yamlSource, _ := settings.NewFileSource("config.yaml")
envSource := settings.NewEnvSource(os.Environ())
finalSource := []settings.MultiSource{yamlSource, envSource}

Recursion protection is such that recursing to a depth of ten will result in the value at the "top" of the recursion stack being returned. For example, the following will return a literal unexpanded value ${b} when getting key a due to infinite recursion protection.

a: "${b}"
b: "${c}"
c: "${a}"

Similarly, the literal value is returned when no expansion is possible. The following will return a literal unexpanded value ${b} when getting key a:

a: "${b}"

Sources may be used as-is by passing them around to components that need to fetch values. However, the values returned from Get() are opaque and highly dependent on the implementation. For example, the ENV source will always return a string representation of the value because that is what is available in the environment. Alternatively, the JSON and YAML sources may return other data types as they typically unmarshal into native go types. Each component fetching values from a source is responsible for safely converting the result into a useful value.

We recommend using one of the API layers we provide to do this for you.

Component API

With one of our goals being the support of plugin based systems, we've built configurable components into the higher level interface of the project. NewComponent is the entry point for the high-level, Component API. This method manages much of the complexity of adding configuration to a system.

func NewComponent(ctx context.Context, s settings.Source, value interface{}, destination interface{}) error

The given context and source are used for all lookups of configuration data. The given value must be an implementation of the component contract and the destination is a pointer created with new(T) where T is the output type (or equivalent convertible) to the element produced by the component contract implementation.

The component contract is an interface that all input values must conform to and is roughly equivalent to the Factory or Constructor concepts. Each instance of the component contract must define two methods: Setting() C and New(context.Context, C) (T, error). Due to the lack of generics in go, there's no way to describe this contract as an actual go interface that would benefit from static typing support. As a result, NewComponent uses reflection to enforce the contract in order to allow for C to be any type that is convertible to configuration via the settings.Convert() method and for T to be any type that your use case requires.

For example, the most minimal implementation of the contract would look like:

// All configuration is driven by a struct that uses standard go types.
type Config struct {}
// The resulting element is virtually anything that you need it to be
// for the purposes of the system you are building.
type Result struct {}
// The component contract interfaces between the thing you want to make,
// the result, and the settings project. It is responsible for producing
// instances of the configuration struct with any default values populated.
// It is also used to construct new instances of the result using a
// populated configuration. No references to the settings project are
// required to create any part of this setup.
type Component struct {}
func (*Component) Settings() *Config { return &Config{} }
func (*Component) New(_ context.Context, c *Config) (*Result, error) {
    return &Result{}, nil
}

From here, any number of settings and sub-trees may be added to Config, any methods or attributes may be added to Result, and any complexity in the creation of Result may be be added to the Component.New method. To then use this basic example as a component you would:

component := &Component{}
r := new(Result)
err := settings.NewComponent(context.Background(), source, component, r)

If the resulting error is nil then the destination value, r in this example, now points to the output of the Component.New method. The method returns an error any time the given component does not satisfy the contract, any time the configuration loading fails, or the Component.New returns an error.

The benefits of using this API are that it is highly flexible with respect to types and it prevents plugins or components from needing to import and using elements from this project. This makes it a bit easier to write tests by removing the need to orchestrate an entire configuration system.

A potential downside to this API is that the resulting configuration hierarchy is not easily modified. The structure is enforced is such that each component receives a top level key and all nested structs result in sub-trees. The name of every setting is generated from the field name and this is not changeable. The description of each field can be set using struct tags. The name and description of each tree may be defined by implementing a Name() and Description() method but the overall arrangement is fixed.

type InnerConfig struct {
    Value2 string `description:"a string"`
}
func (c *InnerConfig) Name() string {
    return "subtree"
}
func (c *InnerConfig) Description() string {
    return "a nesting configuration tree"
}

type OuterConfig struct {
    Value1 int `description:"the first value"`
    InnerConfig *InnerConfig
}
func (c *OuterConfig) Name() string {
    return "toptree"
}
func (c *OuterConfig) Description() string {
    return "the top configuration tree"
}

The above will equate to a configuration like:

toptree:
    value1: 0
    subtree:
        value2: ""

The descriptions are used to annotate example configurations and help output.

Hierarchy API

If the component API is too restrictive for your use case then the Hierarchy API might be of more use. This layer of the API is based on the settings.Setting and settings.Group types which represent individual configuration options and sub-trees, respectively. We include a settings.SettingGroup implementation of the settings.Group which allows you to construct any arbitrary hierarchy of configuration as needed. It also, however, requires coupling the code to this project and using a much more verbose style of defining options:

top := &settings.SettingGroup{
    NameValue: "root",
    GroupValues: []settings.Group{}, // Add any sub-tree here.
    SettingValues: []settings.Setting{ // Add any settings here.
        settings.NewIntSetting("Value1", "an int value", 2),
    },
}

err := settings.LoadGroups(finalSource, []Group{top})

After loading is complete, each Setting value will contain either the given default for a the value found in the Source. This is the same API we used to create the Component API.

Adapter API

If none of the higher API layers provide what you need then we also offer a lower level tool set for creating new high level APIs. At the most basic level, this project contains a large set of strongly types adapters for content pulled from a source. Each adapter is responsible for converting from the empty interface into a native type that the setting exposes. These are named with the pattern of <Type>Setting. We make use of the cast project to handle converting from arbitrary types to target types.

This is the layer to target when adding new supported configuration types or when replacing the type converters with something else. These elements, in possible conjunction with elements from the Hierarchy API, are flexible enough to build anything you need.

Special Type Parsing and Casting

We use the cast library for casting values read in from configurations into their go types. The cast library falls back to JSON for complex types expressed as string values. Here are some examples of how we parse different types:

[]string

For a given configuration

type Config struct {
    TheSlice []string
}

The values in the following examples will all be parsed as a string slice.

yaml

config:
  theslice:
    - "a"
    - "b"
    - "c"

JSON

{"config": {"theslice":  ["a", "b", "c"]}}

You can also set an environment variable and reference them in a YAML or JSON file like below. Note that this environment variable value will be parsed as a slice where each letter will be a value since it gets split by any space in the string.

Environment Variable

CONFIG_THESLICE="a b c"`

yaml

config:
  theslice: "${CONFIG_THESLICE}"

JSON

{"config": {"theslice":  "${CONFIG_THESLICE}"}}

map[string][]string

For a given configuration

type Config struct {
    allowedStrings map[string][]string
}

The values in the following examples will all be parsed as a string map string slices where the key letters and symbols gets included as the string map key and their values are a string slice.

yaml

config:
  allowedStrings:
    letters:
      - "a"
      - "b"
      - "c"
    symbols:
      - "@"
      - "!"

JSON

{
	"config": {
		"allowedStrings": {
			"letters": ["a", "b", "c"],
			"symbols": ["@", "!"]
		}
	}
}

map[string]string

For a given configuration

type Config struct {
    foods map[string]string
}

The values in the following examples will all be parsed as a string map of string values where the key and values get included as the string map key to string values.

yaml

config:
  foods:
    apple: "fruit"
    broccoli: "vegetable"

JSON

{
	"config": {
		"foods": {
			"apple": "fruit",
			"broccoli": "vegetable"
		}
	}
}

time.Time

For a given configuration

type Config struct {
    TheTime time.Time
}

The following examples will be parsed using the RFC3339 format by time.Parse(time.RFC3339, value)

yaml

"config":
  "thetime": "2012-11-01T22:08:41+00:00"

JSON

{"config": {"thetime": "2012-11-01T22:08:41+00:00"}}

Environment Variable

CONFIG_THETIME="2012-11-01T22:08:41+00:00"`

time.Duration

For a given configuration

type Config struct {
	TimeLength time.Duration
}

The following examples will be parsed using time.Duration yaml

"config":
  "timeLength": "4h"

JSON

{"config": {"timeLength": "4h"}}

Environment Variable

CONFIG_TIMELENGTH="4h"

Contributing

License

This project is licensed under Apache 2.0. See LICENSE.txt for details.

Contributing Agreement

Atlassian requires signing a contributor's agreement before we can accept a patch. If you are an individual you can fill out the individual CLA. If you are contributing on behalf of your company then please fill out the corporate CLA.