Skip to content

Commit 351c819

Browse files
authored
Refactor environment variable handling into new Sources::EnvSource (#299)
* Extract env var handling to new EnvSource class * Allow overriding settings for parsing env sources By default, EnvSource will use "global" settings specified like `Config.env_prefix`, `Config.env_separator`, `Config.env_separator`, and `Config.env_parse_values`. Those configurations will be used when parsing the ENV hash. But when using EnvSource to load settings from some unrelated flat string-keyed Hash source, we want to allow folks to override the settings. * Update CHANGELOG * Add AWS Secrets Manager usage to README
1 parent 2748274 commit 351c819

6 files changed

Lines changed: 204 additions & 54 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,16 @@
55
### BREAKING CHANGES
66

77
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.
8+
`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.
89

910
### Bug fixes
1011

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

14+
### Changes
15+
16+
* 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))
17+
1318
## 2.2.3
1419

1520
### Bug fixes

README.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,50 @@ Settings.section.server # => 'google.com'
480480
Settings.section.ssl_enabled # => false
481481
```
482482

483+
### Working with AWS Secrets Manager
484+
485+
It is possible to parse variables stored in an AWS Secrets Manager Secret as if they were environment variables by using `Config::Sources::EnvSource`.
486+
487+
For example, the plaintext secret might look like this:
488+
489+
```json
490+
{
491+
"Settings.foo": "hello",
492+
"Settings.bar": "world",
493+
}
494+
```
495+
496+
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.
497+
498+
```ruby
499+
# fetch secrets from AWS
500+
client = Aws::SecretsManager::Client.new
501+
response = client.get_secret_value(secret_id: "#{ENV['ENVIRONMENT']}/my_application")
502+
secrets = JSON.parse(response.secret_string)
503+
504+
# load secrets into config
505+
secret_source = Config::Sources::EnvSource.new(secrets)
506+
Settings.add_source!(secret_source)
507+
Settings.reload!
508+
```
509+
510+
In this case, the following settings will be available:
511+
512+
```ruby
513+
Settings.foo # => "hello"
514+
Settings.bar # => "world"
515+
```
516+
517+
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.
518+
519+
```ruby
520+
secret_source = Config::Sources::EnvSource.new(secrets,
521+
prefix: 'MyConfig',
522+
separator: '__',
523+
converter: nil,
524+
parse_values: false)
525+
```
526+
483527
## Contributing
484528

485529
You are very warmly welcome to help. Please follow our [contribution guidelines](CONTRIBUTING.md)

lib/config.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
require 'config/version'
55
require 'config/sources/yaml_source'
66
require 'config/sources/hash_source'
7+
require 'config/sources/env_source'
78
require 'config/validation/schema'
89
require 'deep_merge'
910

@@ -41,6 +42,8 @@ def self.load_files(*files)
4142
config.add_source!(file.to_s)
4243
end
4344

45+
config.add_source!(Sources::EnvSource.new(ENV)) if Config.use_env
46+
4447
config.load!
4548
config
4649
end

lib/config/options.rb

Lines changed: 0 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -31,47 +31,6 @@ def prepend_source!(source)
3131
@config_sources.unshift(source)
3232
end
3333

34-
def reload_env!
35-
return self if ENV.nil? || ENV.empty?
36-
37-
hash = Hash.new
38-
39-
ENV.each do |variable, value|
40-
separator = Config.env_separator
41-
prefix = (Config.env_prefix || Config.const_name).to_s.split(separator)
42-
43-
keys = variable.to_s.split(separator)
44-
45-
next if keys.shift(prefix.size) != prefix
46-
47-
keys.map! { |key|
48-
case Config.env_converter
49-
when :downcase then
50-
key.downcase.to_sym
51-
when nil then
52-
key.to_sym
53-
else
54-
raise "Invalid ENV variables name converter: #{Config.env_converter}"
55-
end
56-
}
57-
58-
leaf = keys[0...-1].inject(hash) { |h, key|
59-
h[key] ||= {}
60-
}
61-
62-
unless leaf.is_a?(Hash)
63-
conflicting_key = (prefix + keys[0...-1]).join(separator)
64-
raise "Environment variable #{variable} conflicts with variable #{conflicting_key}"
65-
end
66-
67-
leaf[keys.last] = Config.env_parse_values ? __value(value) : value
68-
end
69-
70-
merge!(hash)
71-
end
72-
73-
alias :load_env! :reload_env!
74-
7534
# look through all our sources and rebuild the configuration
7635
def reload!
7736
conf = {}
@@ -96,7 +55,6 @@ def reload!
9655
# swap out the contents of the OStruct with a hash (need to recursively convert)
9756
marshal_load(__convert(conf).marshal_dump)
9857

99-
reload_env! if Config.use_env
10058
validate!
10159

10260
self
@@ -223,17 +181,5 @@ def __convert(h) #:nodoc:
223181
end
224182
s
225183
end
226-
227-
# Try to convert string to a correct type
228-
def __value(v)
229-
case v
230-
when 'false'
231-
false
232-
when 'true'
233-
true
234-
else
235-
Integer(v) rescue Float(v) rescue v
236-
end
237-
end
238184
end
239185
end

lib/config/sources/env_source.rb

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
module Config
2+
module Sources
3+
# Allows settings to be loaded from a "flat" hash with string keys, like ENV.
4+
class EnvSource
5+
attr_reader :prefix
6+
attr_reader :separator
7+
attr_reader :converter
8+
attr_reader :parse_values
9+
10+
def initialize(env,
11+
prefix: Config.env_prefix || Config.const_name,
12+
separator: Config.env_separator,
13+
converter: Config.env_converter,
14+
parse_values: Config.env_parse_values)
15+
@env = env
16+
@prefix = prefix.to_s.split(separator)
17+
@separator = separator
18+
@converter = converter
19+
@parse_values = parse_values
20+
end
21+
22+
def load
23+
return {} if @env.nil? || @env.empty?
24+
25+
hash = Hash.new
26+
27+
@env.each do |variable, value|
28+
keys = variable.to_s.split(separator)
29+
30+
next if keys.shift(prefix.size) != prefix
31+
32+
keys.map! { |key|
33+
case converter
34+
when :downcase then
35+
key.downcase
36+
when nil then
37+
key
38+
else
39+
raise "Invalid ENV variables name converter: #{converter}"
40+
end
41+
}
42+
43+
leaf = keys[0...-1].inject(hash) { |h, key|
44+
h[key] ||= {}
45+
}
46+
47+
unless leaf.is_a?(Hash)
48+
conflicting_key = (prefix + keys[0...-1]).join(separator)
49+
raise "Environment variable #{variable} conflicts with variable #{conflicting_key}"
50+
end
51+
52+
leaf[keys.last] = parse_values ? __value(value) : value
53+
end
54+
55+
hash
56+
end
57+
58+
private
59+
60+
# Try to convert string to a correct type
61+
def __value(v)
62+
case v
63+
when 'false'
64+
false
65+
when 'true'
66+
true
67+
else
68+
Integer(v) rescue Float(v) rescue v
69+
end
70+
end
71+
end
72+
end
73+
end

spec/sources/env_source_spec.rb

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
require 'spec_helper'
2+
3+
module Config::Sources
4+
describe EnvSource do
5+
context 'configuration options' do
6+
before :each do
7+
Config.reset
8+
Config.env_prefix = nil
9+
Config.env_separator = '.'
10+
Config.env_converter = :downcase
11+
Config.env_parse_values = true
12+
end
13+
14+
context 'default configuration' do
15+
it 'should use global prefix configuration by default' do
16+
Config.env_prefix = 'MY_CONFIG'
17+
18+
source = EnvSource.new({ 'MY_CONFIG.ACTION_MAILER' => 'enabled' })
19+
results = source.load
20+
expect(results['action_mailer']).to eq('enabled')
21+
end
22+
23+
it 'should use global separator configuration by default' do
24+
Config.env_separator = '__'
25+
26+
source = EnvSource.new({ 'Settings__ACTION_MAILER__ENABLED' => 'yes' })
27+
results = source.load
28+
expect(results['action_mailer']['enabled']).to eq('yes')
29+
end
30+
31+
it 'should use global converter configuration by default' do
32+
Config.env_converter = nil
33+
34+
source = EnvSource.new({ 'Settings.ActionMailer.Enabled' => 'yes' })
35+
results = source.load
36+
expect(results['ActionMailer']['Enabled']).to eq('yes')
37+
end
38+
39+
it 'should use global parse_values configuration by default' do
40+
Config.env_parse_values = false
41+
42+
source = EnvSource.new({ 'Settings.ACTION_MAILER.ENABLED' => 'true' })
43+
results = source.load
44+
expect(results['action_mailer']['enabled']).to eq('true')
45+
end
46+
end
47+
48+
context 'configuration overrides' do
49+
it 'should allow overriding prefix configuration' do
50+
source = EnvSource.new({ 'MY_CONFIG.ACTION_MAILER' => 'enabled' },
51+
prefix: 'MY_CONFIG')
52+
results = source.load
53+
expect(results['action_mailer']).to eq('enabled')
54+
end
55+
56+
it 'should allow overriding separator configuration' do
57+
source = EnvSource.new({ 'Settings__ACTION_MAILER__ENABLED' => 'yes' },
58+
separator: '__')
59+
results = source.load
60+
expect(results['action_mailer']['enabled']).to eq('yes')
61+
end
62+
63+
it 'should allow overriding converter configuration' do
64+
source = EnvSource.new({ 'Settings.ActionMailer.Enabled' => 'yes' },
65+
converter: nil)
66+
results = source.load
67+
expect(results['ActionMailer']['Enabled']).to eq('yes')
68+
end
69+
70+
it 'should allow overriding parse_values configuration' do
71+
source = EnvSource.new({ 'Settings.ACTION_MAILER.ENABLED' => 'true' },
72+
parse_values: false)
73+
results = source.load
74+
expect(results['action_mailer']['enabled']).to eq('true')
75+
end
76+
end
77+
end
78+
end
79+
end

0 commit comments

Comments
 (0)