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

Refactor environment variable handling into new Sources::EnvSource #299

Merged
merged 5 commits into from
Feb 25, 2021
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,16 @@
### BREAKING CHANGES

After upgrade behaviour of `to_h` would change and match behaviour of `to_hash`. Check [#217](https://github.com/rubyconfig/config/issues/217#issuecomment-741953382) for more details.
`Config::Options#load_env!` and `Config::Options#reload_env!` have been removed. If you need to reload settings after modifying the `ENV` hash, use `Config.reload!` or `Config::Options#reload!` instead.

### Bug fixes

* Added alias `to_h` for `to_hash` ([#277](https://github.com/railsconfig/config/issues/277))

### Changes

* Add `Config::Sources::EnvSource` for loading settings from flat `Hash`es with `String` keys and `String` values ([#299](https://github.com/railsconfig/config/pull/299))

## 2.2.3

### Bug fixes
Expand Down
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,50 @@ Settings.section.server # => 'google.com'
Settings.section.ssl_enabled # => false
```

### Working with AWS Secrets Manager

It is possible to parse variables stored in an AWS Secrets Manager Secret as if they were environment variables by using `Config::Sources::EnvSource`.

For example, the plaintext secret might look like this:

```json
{
"Settings.foo": "hello",
"Settings.bar": "world",
}
```

In order to load those settings, fetch the settings from AWS Secrets Manager, parse the plaintext as JSON, pass the resulting `Hash` into a new `EnvSource`, load the new source, and reload.

```ruby
# fetch secrets from AWS
client = Aws::SecretsManager::Client.new
response = client.get_secret_value(secret_id: "#{ENV['ENVIRONMENT']}/my_application")
secrets = JSON.parse(response.secret_string)

# load secrets into config
secret_source = Config::Sources::EnvSource.new(secrets)
Settings.add_source!(secret_source)
Settings.reload!
```

In this case, the following settings will be available:

```ruby
Settings.foo # => "hello"
Settings.bar # => "world"
```

By default, `EnvSource` will use configuration for `env_prefix`, `env_separator`, `env_converter`, and `env_parse_values`, but any of these can be overridden in the constructor.

```ruby
secret_source = Config::Sources::EnvSource.new(secrets,
prefix: 'MyConfig',
separator: '__',
converter: nil,
parse_values: false)
```

## Contributing

You are very warmly welcome to help. Please follow our [contribution guidelines](CONTRIBUTING.md)
Expand Down
3 changes: 3 additions & 0 deletions lib/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
require 'config/version'
require 'config/sources/yaml_source'
require 'config/sources/hash_source'
require 'config/sources/env_source'
require 'config/validation/schema'
require 'deep_merge'

Expand Down Expand Up @@ -41,6 +42,8 @@ def self.load_files(*files)
config.add_source!(file.to_s)
end

config.add_source!(Sources::EnvSource.new(ENV)) if Config.use_env

config.load!
config
end
Expand Down
54 changes: 0 additions & 54 deletions lib/config/options.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,47 +31,6 @@ def prepend_source!(source)
@config_sources.unshift(source)
end

def reload_env!
return self if ENV.nil? || ENV.empty?

hash = Hash.new

ENV.each do |variable, value|
separator = Config.env_separator
prefix = (Config.env_prefix || Config.const_name).to_s.split(separator)

keys = variable.to_s.split(separator)

next if keys.shift(prefix.size) != prefix

keys.map! { |key|
case Config.env_converter
when :downcase then
key.downcase.to_sym
when nil then
key.to_sym
else
raise "Invalid ENV variables name converter: #{Config.env_converter}"
end
}

leaf = keys[0...-1].inject(hash) { |h, key|
h[key] ||= {}
}

unless leaf.is_a?(Hash)
conflicting_key = (prefix + keys[0...-1]).join(separator)
raise "Environment variable #{variable} conflicts with variable #{conflicting_key}"
end

leaf[keys.last] = Config.env_parse_values ? __value(value) : value
end

merge!(hash)
end

alias :load_env! :reload_env!
Copy link
Member Author

@cjlarose cjlarose Jan 13, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pkuczynski Config::Options#load_env! and Config::Options#reload_env! are not documented at all, but are technically public methods.

As such, depending on whether or not we consider all visible methods to be part of the public API of this gem, this could be considered a breaking change and warrants a major version bump, so I added this under Breaking Changes in the changelog.

The change for users that use #load_env! or #reload_env! is simple: they should just replace those usages with #reload!, which is documented.


# look through all our sources and rebuild the configuration
def reload!
conf = {}
Expand All @@ -96,7 +55,6 @@ def reload!
# swap out the contents of the OStruct with a hash (need to recursively convert)
marshal_load(__convert(conf).marshal_dump)

reload_env! if Config.use_env
validate!

self
Expand Down Expand Up @@ -223,17 +181,5 @@ def __convert(h) #:nodoc:
end
s
end

# Try to convert string to a correct type
def __value(v)
case v
when 'false'
false
when 'true'
true
else
Integer(v) rescue Float(v) rescue v
end
end
end
end
73 changes: 73 additions & 0 deletions lib/config/sources/env_source.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
module Config
module Sources
# Allows settings to be loaded from a "flat" hash with string keys, like ENV.
class EnvSource
attr_reader :prefix
attr_reader :separator
attr_reader :converter
attr_reader :parse_values

def initialize(env,
prefix: Config.env_prefix || Config.const_name,
separator: Config.env_separator,
converter: Config.env_converter,
parse_values: Config.env_parse_values)
@env = env
@prefix = prefix.to_s.split(separator)
@separator = separator
@converter = converter
@parse_values = parse_values
end

def load
return {} if @env.nil? || @env.empty?

hash = Hash.new

@env.each do |variable, value|
keys = variable.to_s.split(separator)

next if keys.shift(prefix.size) != prefix

keys.map! { |key|
case converter
when :downcase then
key.downcase
when nil then
key
else
raise "Invalid ENV variables name converter: #{converter}"
end
}

leaf = keys[0...-1].inject(hash) { |h, key|
h[key] ||= {}
}

unless leaf.is_a?(Hash)
conflicting_key = (prefix + keys[0...-1]).join(separator)
raise "Environment variable #{variable} conflicts with variable #{conflicting_key}"
end

leaf[keys.last] = parse_values ? __value(value) : value
end

hash
end

private

# Try to convert string to a correct type
def __value(v)
case v
when 'false'
false
when 'true'
true
else
Integer(v) rescue Float(v) rescue v
end
end
end
end
end
79 changes: 79 additions & 0 deletions spec/sources/env_source_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
require 'spec_helper'

module Config::Sources
describe EnvSource do
context 'configuration options' do
before :each do
Config.reset
Config.env_prefix = nil
Config.env_separator = '.'
Config.env_converter = :downcase
Config.env_parse_values = true
end

context 'default configuration' do
it 'should use global prefix configuration by default' do
Config.env_prefix = 'MY_CONFIG'

source = EnvSource.new({ 'MY_CONFIG.ACTION_MAILER' => 'enabled' })
results = source.load
expect(results['action_mailer']).to eq('enabled')
end

it 'should use global separator configuration by default' do
Config.env_separator = '__'

source = EnvSource.new({ 'Settings__ACTION_MAILER__ENABLED' => 'yes' })
results = source.load
expect(results['action_mailer']['enabled']).to eq('yes')
end

it 'should use global converter configuration by default' do
Config.env_converter = nil

source = EnvSource.new({ 'Settings.ActionMailer.Enabled' => 'yes' })
results = source.load
expect(results['ActionMailer']['Enabled']).to eq('yes')
end

it 'should use global parse_values configuration by default' do
Config.env_parse_values = false

source = EnvSource.new({ 'Settings.ACTION_MAILER.ENABLED' => 'true' })
results = source.load
expect(results['action_mailer']['enabled']).to eq('true')
end
end

context 'configuration overrides' do
it 'should allow overriding prefix configuration' do
source = EnvSource.new({ 'MY_CONFIG.ACTION_MAILER' => 'enabled' },
prefix: 'MY_CONFIG')
results = source.load
expect(results['action_mailer']).to eq('enabled')
end

it 'should allow overriding separator configuration' do
source = EnvSource.new({ 'Settings__ACTION_MAILER__ENABLED' => 'yes' },
separator: '__')
results = source.load
expect(results['action_mailer']['enabled']).to eq('yes')
end

it 'should allow overriding converter configuration' do
source = EnvSource.new({ 'Settings.ActionMailer.Enabled' => 'yes' },
converter: nil)
results = source.load
expect(results['ActionMailer']['Enabled']).to eq('yes')
end

it 'should allow overriding parse_values configuration' do
source = EnvSource.new({ 'Settings.ACTION_MAILER.ENABLED' => 'true' },
parse_values: false)
results = source.load
expect(results['action_mailer']['enabled']).to eq('true')
end
end
end
end
end