blueprint_config 1.3.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -108,5 +108,25 @@ module BlueprintConfig
108
108
  def deep_merge?(other)
109
109
  other.is_a?(self.class)
110
110
  end
111
+
112
+ def with_sources
113
+ result = {}
114
+ each do |key, value|
115
+ if value.is_a?(BlueprintConfig::OptionsHash)
116
+ result[key] = value.with_sources
117
+ elsif value.is_a?(BlueprintConfig::OptionsArray)
118
+ result[key] = {
119
+ value: value.to_a,
120
+ source: @__sources[key]
121
+ }
122
+ else
123
+ result[key] = {
124
+ value: value,
125
+ source: @__sources[key]
126
+ }
127
+ end
128
+ end
129
+ result
130
+ end
111
131
  end
112
132
  end
@@ -6,7 +6,7 @@ module BlueprintConfig
6
6
  class Setting < ::ActiveRecord::Base
7
7
  self.inheritance_column = nil
8
8
 
9
- enum type: { section: 0, string: 1, integer: 2, boolean: 3, json: 4, selection: 5, set: 6 }
9
+ enum :type, { section: 0, string: 1, integer: 2, boolean: 3, json: 4, selection: 5, set: 6 }
10
10
 
11
11
  def parsed_json_value
12
12
  parsed = begin
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BlueprintConfig
4
- VERSION = '1.3.1'
4
+ VERSION = '1.5.0'
5
5
  end
@@ -3,6 +3,7 @@
3
3
  require 'blueprint_config/version'
4
4
  require 'blueprint_config/backend/env'
5
5
  require 'blueprint_config/backend/yaml'
6
+ require 'blueprint_config/backend/memory'
6
7
  require 'blueprint_config/configuration'
7
8
  require 'blueprint_config/backend_collection'
8
9
 
@@ -59,6 +60,11 @@ BlueprintConfig.before_initialize ||= proc do
59
60
  backends.use :credentials, BlueprintConfig::Backend::Credentials.new
60
61
  backends.use :env, BlueprintConfig::Backend::ENV.new(BlueprintConfig.env_backend_options)
61
62
  backends.use :app_local, BlueprintConfig::Backend::YAML.new('config/app.local.yml')
63
+
64
+ # Memory backend with highest priority for test environment (added last)
65
+ if defined?(Rails) && Rails.env.test?
66
+ backends.use :memory, BlueprintConfig::Backend::Memory.new
67
+ end
62
68
  end
63
69
  end
64
70
 
@@ -71,5 +77,12 @@ BlueprintConfig.after_initialize ||= proc do
71
77
  else
72
78
  backends.push :db, ar_backend
73
79
  end
80
+
81
+ # Ensure memory backend is always last (highest priority) in test environment
82
+ if defined?(Rails) && Rails.env.test? && backends[:memory]
83
+ memory_backend = backends[:memory]
84
+ backends.delete(:memory)
85
+ backends.push :memory, memory_backend
86
+ end
74
87
  end
75
88
  end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'blueprint_config'
5
+ require 'blueprint_config/backend/yaml'
6
+ require 'blueprint_config/backend/credentials'
7
+ require 'blueprint_config/backend/env'
8
+ require 'blueprint_config/backend/active_record'
9
+ require 'blueprint_config/backend/memory'
10
+
11
+ describe 'Backend source tracking' do
12
+ describe BlueprintConfig::Backend::YAML do
13
+ let(:yaml_backend) { described_class.new('config/test.yml') }
14
+
15
+ describe '#source' do
16
+ it 'includes the file path' do
17
+ expect(yaml_backend.source).to eq('BlueprintConfig::Backend::YAML(config/test.yml)')
18
+ end
19
+ end
20
+ end
21
+
22
+ describe BlueprintConfig::Backend::Credentials do
23
+ let(:credentials_backend) { described_class.new }
24
+
25
+ describe '#source' do
26
+ context 'without Rails' do
27
+ before do
28
+ hide_const('Rails')
29
+ end
30
+
31
+ it 'returns just the class name' do
32
+ expect(credentials_backend.source).to eq('BlueprintConfig::Backend::Credentials')
33
+ end
34
+ end
35
+
36
+ context 'with Rails in development' do
37
+ before do
38
+ unless defined?(Rails)
39
+ module Rails
40
+ def self.env
41
+ ActiveSupport::StringInquirer.new('development')
42
+ end
43
+ def self.root
44
+ Pathname.new('/app')
45
+ end
46
+ end
47
+ end
48
+ end
49
+
50
+ after do
51
+ Object.send(:remove_const, :Rails) if defined?(Rails)
52
+ end
53
+
54
+ it 'shows environment info when no env-specific file exists' do
55
+ allow(File).to receive(:exist?).with('config/credentials/development.yml.enc').and_return(false)
56
+ expect(credentials_backend.source).to eq('BlueprintConfig::Backend::Credentials(global)')
57
+ end
58
+
59
+ it 'shows merged info when env-specific file exists' do
60
+ allow(File).to receive(:exist?).with('config/credentials/development.yml.enc').and_return(true)
61
+ expect(credentials_backend.source).to eq('BlueprintConfig::Backend::Credentials(global + development)')
62
+ end
63
+ end
64
+ end
65
+ end
66
+
67
+ describe BlueprintConfig::Backend::ENV do
68
+ describe '#source' do
69
+ it 'returns class name when no options' do
70
+ env_backend = described_class.new
71
+ expect(env_backend.source).to eq('BlueprintConfig::Backend::ENV')
72
+ end
73
+
74
+ it 'includes whitelist_keys when configured' do
75
+ env_backend = described_class.new(whitelist_keys: ['API_KEY', 'SECRET'])
76
+ expect(env_backend.source).to eq('BlueprintConfig::Backend::ENV(whitelist_keys: API_KEY, SECRET)')
77
+ end
78
+
79
+ it 'includes whitelist_prefixes when configured' do
80
+ env_backend = described_class.new(whitelist_prefixes: ['APP_', 'MYAPP_'])
81
+ expect(env_backend.source).to eq('BlueprintConfig::Backend::ENV(whitelist_prefixes: APP_, MYAPP_)')
82
+ end
83
+
84
+ it 'includes both options when configured' do
85
+ env_backend = described_class.new(
86
+ whitelist_keys: ['API_KEY'],
87
+ whitelist_prefixes: ['APP_']
88
+ )
89
+ expect(env_backend.source).to eq('BlueprintConfig::Backend::ENV(whitelist_keys: API_KEY, whitelist_prefixes: APP_)')
90
+ end
91
+ end
92
+ end
93
+
94
+ describe BlueprintConfig::Backend::ActiveRecord do
95
+ let(:ar_backend) { described_class.new }
96
+
97
+ describe '#source' do
98
+ context 'when table exists' do
99
+ before do
100
+ allow(ar_backend).to receive(:table_exist?).and_return(true)
101
+ ar_backend.instance_variable_set(:@configured, true)
102
+ end
103
+
104
+ it 'shows settings table' do
105
+ expect(ar_backend.source).to eq('BlueprintConfig::Backend::ActiveRecord(settings table)')
106
+ end
107
+ end
108
+
109
+ context 'when not configured' do
110
+ before do
111
+ ar_backend.instance_variable_set(:@configured, false)
112
+ end
113
+
114
+ it 'shows not available' do
115
+ expect(ar_backend.source).to eq('BlueprintConfig::Backend::ActiveRecord(not available)')
116
+ end
117
+ end
118
+ end
119
+ end
120
+
121
+ describe BlueprintConfig::Backend::Memory do
122
+ let(:memory_backend) { described_class.new }
123
+
124
+ describe '#source' do
125
+ it 'returns the class name' do
126
+ expect(memory_backend.source).to eq('BlueprintConfig::Backend::Memory')
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'blueprint_config/backend/memory'
4
+
5
+ describe BlueprintConfig::Backend::Memory do
6
+ let(:backend) { described_class.new }
7
+
8
+ describe '#load_keys' do
9
+ it 'returns empty hash initially' do
10
+ expect(backend.load_keys).to eq({})
11
+ end
12
+
13
+ it 'returns stored values as nested structure' do
14
+ backend.set('foo', 'bar')
15
+ expect(backend.load_keys).to eq({ foo: 'bar' })
16
+ end
17
+
18
+ it 'returns a copy of the store' do
19
+ backend.set('foo', 'bar')
20
+ loaded = backend.load_keys
21
+ loaded[:baz] = 'qux'
22
+ expect(backend.load_keys).to eq({ foo: 'bar' })
23
+ end
24
+ end
25
+
26
+ describe '#set' do
27
+ context 'with simple key-value' do
28
+ it 'stores the value' do
29
+ backend.set('foo', 'bar')
30
+ expect(backend.load_keys[:foo]).to eq('bar')
31
+ end
32
+
33
+ it 'converts key to string' do
34
+ backend.set(:foo, 'bar')
35
+ expect(backend.load_keys[:foo]).to eq('bar')
36
+ end
37
+
38
+ it 'overwrites existing value' do
39
+ backend.set('foo', 'bar')
40
+ backend.set('foo', 'baz')
41
+ expect(backend.load_keys[:foo]).to eq('baz')
42
+ end
43
+
44
+ it 'handles numeric values' do
45
+ backend.set('port', 3000)
46
+ expect(backend.load_keys[:port]).to eq(3000)
47
+ end
48
+
49
+ it 'handles boolean values' do
50
+ backend.set('enabled', true)
51
+ expect(backend.load_keys[:enabled]).to be true
52
+ end
53
+
54
+ it 'handles nil values' do
55
+ backend.set('empty', nil)
56
+ expect(backend.load_keys[:empty]).to be nil
57
+ end
58
+ end
59
+
60
+ context 'with nested hash value' do
61
+ it 'creates nested structure from single level hash' do
62
+ backend.set('smtp', { server: 'localhost', port: 1025 })
63
+ expect(backend.load_keys).to eq({
64
+ smtp: {
65
+ server: 'localhost',
66
+ port: 1025
67
+ }
68
+ })
69
+ end
70
+
71
+ it 'creates nested structure from deeply nested hash' do
72
+ backend.set('app', {
73
+ mail: {
74
+ smtp: {
75
+ settings: {
76
+ address: '127.0.0.1',
77
+ port: 587
78
+ }
79
+ }
80
+ }
81
+ })
82
+ expect(backend.load_keys).to eq({
83
+ app: {
84
+ mail: {
85
+ smtp: {
86
+ settings: {
87
+ address: '127.0.0.1',
88
+ port: 587
89
+ }
90
+ }
91
+ }
92
+ }
93
+ })
94
+ end
95
+
96
+ it 'handles empty parent key' do
97
+ backend.set('', { foo: 'bar', baz: 'qux' })
98
+ expect(backend.load_keys).to eq({
99
+ foo: 'bar',
100
+ baz: 'qux'
101
+ })
102
+ end
103
+
104
+ it 'merges with existing values' do
105
+ backend.set('app.name', 'MyApp')
106
+ backend.set('app', { version: '1.0' })
107
+ expect(backend.load_keys).to include(
108
+ app: {
109
+ name: 'MyApp',
110
+ version: '1.0'
111
+ }
112
+ )
113
+ end
114
+
115
+ it 'handles mixed types in nested hash' do
116
+ backend.set('config', {
117
+ string: 'value',
118
+ number: 42,
119
+ boolean: true,
120
+ null: nil,
121
+ nested: { key: 'value' }
122
+ })
123
+
124
+ expect(backend.load_keys).to eq({
125
+ config: {
126
+ string: 'value',
127
+ number: 42,
128
+ boolean: true,
129
+ null: nil,
130
+ nested: {
131
+ key: 'value'
132
+ }
133
+ }
134
+ })
135
+ end
136
+ end
137
+
138
+ context 'with dotted keys' do
139
+ it 'creates nested structure from dotted keys' do
140
+ backend.set('smtp.server', 'mail.example.com')
141
+ backend.set('smtp.port', 587)
142
+ expect(backend.load_keys).to eq({
143
+ smtp: {
144
+ server: 'mail.example.com',
145
+ port: 587
146
+ }
147
+ })
148
+ end
149
+ end
150
+ end
151
+
152
+ describe '#clear' do
153
+ it 'removes all stored values' do
154
+ backend.set('foo', 'bar')
155
+ backend.set('baz', 'qux')
156
+ backend.clear
157
+ expect(backend.load_keys).to eq({})
158
+ end
159
+
160
+ it 'allows setting new values after clear' do
161
+ backend.set('foo', 'bar')
162
+ backend.clear
163
+ backend.set('new', 'value')
164
+ expect(backend.load_keys).to eq({ new: 'value' })
165
+ end
166
+ end
167
+
168
+ describe '#fresh?' do
169
+ it 'always returns true' do
170
+ expect(backend.fresh?).to be true
171
+ backend.set('foo', 'bar')
172
+ expect(backend.fresh?).to be true
173
+ end
174
+ end
175
+
176
+ describe '#source' do
177
+ it 'returns the class name' do
178
+ expect(backend.source).to eq('BlueprintConfig::Backend::Memory')
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'blueprint_config'
5
+ require 'tmpdir'
6
+ require 'fileutils'
7
+
8
+ describe 'Configuration integration with all features' do
9
+ let(:temp_dir) { Dir.mktmpdir }
10
+ let(:app_yml_path) { File.join(temp_dir, 'config', 'app.yml') }
11
+ let(:app_local_yml_path) { File.join(temp_dir, 'config', 'app.local.yml') }
12
+
13
+ before do
14
+ # Setup directory structure
15
+ FileUtils.mkdir_p(File.join(temp_dir, 'config'))
16
+
17
+ # Mock BlueprintConfig root
18
+ allow(BlueprintConfig).to receive(:root).and_return(temp_dir)
19
+ allow(BlueprintConfig).to receive(:env).and_return('test')
20
+
21
+ # Create YAML files
22
+ File.write(app_yml_path, <<~YAML)
23
+ default:
24
+ app:
25
+ name: MyApp
26
+ version: 1.0
27
+ smtp:
28
+ server: smtp.example.com
29
+ port: 587
30
+ test:
31
+ app:
32
+ name: MyApp Test
33
+ smtp:
34
+ port: 1025
35
+ YAML
36
+
37
+ File.write(app_local_yml_path, <<~YAML)
38
+ default:
39
+ debug:
40
+ verbose: true
41
+ smtp:
42
+ server: localhost
43
+ YAML
44
+
45
+ # Mock Rails for credentials
46
+ unless defined?(Rails)
47
+ module Rails
48
+ def self.env
49
+ ActiveSupport::StringInquirer.new('test')
50
+ end
51
+ def self.application
52
+ Struct.new(:credentials).new(
53
+ OpenStruct.new(
54
+ database: { password: 'secret123' },
55
+ api: { token: 'api_key_123' }
56
+ )
57
+ )
58
+ end
59
+ end
60
+ end
61
+
62
+ # Set some ENV variables
63
+ ENV['APP_HOST'] = 'test.example.com'
64
+ ENV['APP_FEATURE_NEW_UI'] = 'true'
65
+
66
+ # Initialize configuration
67
+ BlueprintConfig.instance.instance_variable_set(:@backends, nil)
68
+ BlueprintConfig.instance.instance_variable_set(:@config, nil)
69
+ BlueprintConfig.before_initialize.call
70
+ BlueprintConfig.after_initialize.call
71
+ BlueprintConfig.define_shortcut
72
+ end
73
+
74
+ after do
75
+ FileUtils.rm_rf(temp_dir)
76
+ ENV.delete('APP_HOST')
77
+ ENV.delete('APP_FEATURE_NEW_UI')
78
+
79
+ # Clean up memory backend
80
+ if defined?(AppConfig) && AppConfig.respond_to?(:clear_memory!)
81
+ AppConfig.clear_memory!
82
+ end
83
+
84
+ Object.send(:remove_const, :Rails) if defined?(Rails)
85
+ Object.send(:remove_const, :AppConfig) if defined?(AppConfig)
86
+ end
87
+
88
+ describe 'configuration loading and precedence' do
89
+ it 'loads values from all backends' do
90
+ # From app.yml default section
91
+ expect(AppConfig.app.version).to eq(1.0)
92
+
93
+ # From app.yml test section (overrides default)
94
+ expect(AppConfig.app.name).to eq('MyApp Test')
95
+
96
+ # From credentials
97
+ expect(AppConfig.database.password).to eq('secret123')
98
+ expect(AppConfig.api.token).to eq('api_key_123')
99
+
100
+ # From app.local.yml (highest file priority)
101
+ expect(AppConfig.debug.verbose).to be true
102
+ expect(AppConfig.smtp.server).to eq('localhost')
103
+ end
104
+
105
+ it 'respects configuration precedence' do
106
+ # app.yml sets port to 587, test section overrides to 1025
107
+ expect(AppConfig.smtp.port).to eq(1025)
108
+
109
+ # app.local.yml overrides server from app.yml
110
+ expect(AppConfig.smtp.server).to eq('localhost')
111
+ end
112
+ end
113
+
114
+ describe 'memory backend in test environment' do
115
+ it 'allows setting values that override all other sources' do
116
+ # Original value from app.local.yml
117
+ expect(AppConfig.smtp.server).to eq('localhost')
118
+
119
+ # Override with memory backend
120
+ AppConfig.set('smtp.server', 'memory.example.com')
121
+ expect(AppConfig.smtp.server).to eq('memory.example.com')
122
+
123
+ # Clear memory
124
+ AppConfig.clear_memory!
125
+ expect(AppConfig.smtp.server).to eq('localhost')
126
+ end
127
+
128
+ it 'supports setting nested values with hash' do
129
+ AppConfig.set(
130
+ features: {
131
+ new_ui: true,
132
+ beta: false,
133
+ limits: {
134
+ max_users: 100,
135
+ max_projects: 10
136
+ }
137
+ }
138
+ )
139
+
140
+ expect(AppConfig.features.new_ui).to be true
141
+ expect(AppConfig.features.beta).to be false
142
+ expect(AppConfig.features.limits.max_users).to eq(100)
143
+ expect(AppConfig.features.limits.max_projects).to eq(10)
144
+ end
145
+ end
146
+
147
+ describe 'source tracking' do
148
+ it 'tracks sources for values from different backends' do
149
+ expect(AppConfig.config.source(:app, :name)).to include('YAML(config/app.yml)')
150
+ expect(AppConfig.config.source(:database, :password)).to include('Credentials')
151
+ expect(AppConfig.config.source(:debug, :verbose)).to include('YAML(config/app.local.yml)')
152
+ end
153
+
154
+ it 'shows memory backend source when value is overridden' do
155
+ AppConfig.set('test.value', 'from memory')
156
+ expect(AppConfig.config.source(:test, :value)).to include('Memory')
157
+ end
158
+ end
159
+
160
+ describe 'with_sources method' do
161
+ it 'returns all values with their sources' do
162
+ result = AppConfig.config.with_sources
163
+
164
+ # Check structure
165
+ expect(result).to be_a(Hash)
166
+ expect(result[:app][:name]).to be_a(Hash)
167
+ expect(result[:app][:name]).to have_key(:value)
168
+ expect(result[:app][:name]).to have_key(:source)
169
+
170
+ # Check values and sources
171
+ expect(result[:app][:name][:value]).to eq('MyApp Test')
172
+ expect(result[:app][:name][:source]).to include('YAML')
173
+
174
+ expect(result[:database][:password][:value]).to eq('secret123')
175
+ expect(result[:database][:password][:source]).to include('Credentials')
176
+ end
177
+
178
+ it 'includes memory backend sources' do
179
+ AppConfig.set('dynamic.setting', 'test value')
180
+ result = AppConfig.config.with_sources
181
+
182
+ expect(result[:dynamic][:setting][:value]).to eq('test value')
183
+ expect(result[:dynamic][:setting][:source]).to include('Memory')
184
+ end
185
+ end
186
+
187
+ describe 'configuration access methods' do
188
+ it 'supports member access syntax' do
189
+ expect(AppConfig.app.name).to eq('MyApp Test')
190
+ end
191
+
192
+ it 'supports hash access syntax' do
193
+ expect(AppConfig[:app][:name]).to eq('MyApp Test')
194
+ expect(AppConfig['app']['name']).to eq('MyApp Test')
195
+ end
196
+
197
+ it 'supports dig method' do
198
+ expect(AppConfig.dig(:app, :name)).to eq('MyApp Test')
199
+ expect(AppConfig.dig(:non, :existent)).to be_nil
200
+ end
201
+
202
+ it 'supports bang methods for missing keys' do
203
+ expect { AppConfig.missing! }.to raise_error(KeyError, /missing/)
204
+ end
205
+
206
+ it 'supports question methods for checking existence' do
207
+ expect(AppConfig.app?).to be true
208
+ expect(AppConfig.missing?).to be false
209
+ end
210
+ end
211
+
212
+ describe 'convenience methods' do
213
+ it 'supports AppConfig.to_h' do
214
+ result = AppConfig.to_h
215
+ expect(result).to be_a(Hash)
216
+ expect(result[:app][:name]).to eq('MyApp Test')
217
+ expect(result[:database][:password]).to eq('secret123')
218
+ end
219
+
220
+ it 'supports AppConfig.with_sources' do
221
+ result = AppConfig.with_sources
222
+ expect(result).to be_a(Hash)
223
+ expect(result[:app][:name]).to have_key(:value)
224
+ expect(result[:app][:name]).to have_key(:source)
225
+ expect(result[:app][:name][:value]).to eq('MyApp Test')
226
+ expect(result[:app][:name][:source]).to include('YAML')
227
+ end
228
+ end
229
+
230
+ describe 'backend listing' do
231
+ it 'can list all loaded backends' do
232
+ backends = []
233
+ AppConfig.backends.each { |b| backends << b.class.name }
234
+
235
+ expect(backends).to include('BlueprintConfig::Backend::YAML')
236
+ expect(backends).to include('BlueprintConfig::Backend::Credentials')
237
+ expect(backends).to include('BlueprintConfig::Backend::Memory')
238
+ end
239
+
240
+ it 'can access specific backends' do
241
+ expect(AppConfig.backends[:memory]).to be_a(BlueprintConfig::Backend::Memory)
242
+ expect(AppConfig.backends[:app]).to be_a(BlueprintConfig::Backend::YAML)
243
+ expect(AppConfig.backends[:credentials]).to be_a(BlueprintConfig::Backend::Credentials)
244
+ end
245
+ end
246
+ end