anyway_config 2.0.6 → 2.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/README.md CHANGED
@@ -329,7 +329,9 @@ and then use [Rails generators](#generators) to make your application Anyway Con
329
329
 
330
330
  Your config is filled up with values from the following sources (ordered by priority from low to high):
331
331
 
332
- - `RAILS_ROOT/config/my_cool_gem.yml` (for the current `RAILS_ENV`, supports `ERB`):
332
+ 1) **YAML configuration files**: `RAILS_ROOT/config/my_cool_gem.yml`.
333
+
334
+ Recognizes Rails environment, supports `ERB`:
333
335
 
334
336
  ```yml
335
337
  test:
@@ -341,9 +343,51 @@ development:
341
343
  port: 3000
342
344
  ```
343
345
 
344
- **NOTE:** you can override the default YML lookup path by setting `MYCOOLGEM_CONF` env variable.
346
+ ### Multi-env configuration
347
+
348
+ _⚡️ This feature will be turned on by default in the future releases. You can turn it on now via `config.anyway_config.future.use :unwrap_known_environments`._
349
+
350
+ If the YML does not have keys that are one of the "known" Rails environments (development, production, test)—the same configuration will be available in all environments, similar to non-Rails behavior:
351
+
352
+ ```yml
353
+ host: localhost
354
+ port: 3002
355
+ # These values will be active in all environments
356
+ ```
357
+
358
+ To extend the list of known environments, use the setting in the relevant part of your Rails code:
359
+
360
+ ```ruby
361
+ Rails.application.config.anyway_config.known_environments << "staging"
362
+ ```
363
+
364
+ If your YML defines at least a single "environmental" top-level, you _have_ to separate all your settings per-environment. You can't mix and match:
365
+
366
+ ```yml
367
+ staging:
368
+ host: localhost # This value will be loaded when Rails.env.staging? is true
369
+
370
+ port: 3002 # This value will not be loaded at all
371
+ ```
372
+
373
+ You can specify the lookup path for YAML files in one of the following ways:
374
+
375
+ - By setting `config.anyway_config.default_config_path` to a target directory path:
376
+
377
+ ```ruby
378
+ config.anyway_config.default_config_path = "/etc/configs"
379
+ config.anyway_config.default_config_path = Rails.root.join("etc", "configs")
380
+ ```
381
+
382
+ - By setting `config.anyway_config.default_config_path` to a Proc, which accepts a config name and returns the path:
345
383
 
346
- - `Rails.application.secrets.my_cool_gem` (if `secrets.yml` present):
384
+ ```ruby
385
+ config.anyway_config.default_config_path = ->(name) { Rails.root.join("data", "configs", "#{name}.yml") }
386
+ ```
387
+
388
+ - By overriding a specific config YML file path via the `<NAME>_CONF` env variable, e.g., `MYCOOLGEM_CONF=path/to/cool.yml`
389
+
390
+ 2) **Rails secrets**: `Rails.application.secrets.my_cool_gem` (if `secrets.yml` present).
347
391
 
348
392
  ```yml
349
393
  # config/secrets.yml
@@ -352,7 +396,7 @@ development:
352
396
  port: 4444
353
397
  ```
354
398
 
355
- - `Rails.application.credentials.my_cool_gem` (if supported):
399
+ 3) **Rails credentials**: `Rails.application.credentials.my_cool_gem` (if supported):
356
400
 
357
401
  ```yml
358
402
  my_cool_gem:
@@ -361,7 +405,7 @@ my_cool_gem:
361
405
 
362
406
  **NOTE:** You can backport Rails 6 per-environment credentials to Rails 5.2 app using [this patch](https://gist.github.com/palkan/e27e4885535ff25753aefce45378e0cb).
363
407
 
364
- - `ENV['MYCOOLGEM_*']`.
408
+ 4) **Environment variables**: `ENV['MYCOOLGEM_*']`.
365
409
 
366
410
  See [environment variables](#environment-variables).
367
411
 
@@ -444,7 +488,7 @@ Alternatively, you can call `rails g anyway:app_config name param1 param2 ...`.
444
488
 
445
489
  The default data loading mechanism for non-Rails applications is the following (ordered by priority from low to high):
446
490
 
447
- - `./config/<config-name>.yml` (`ERB` is supported if `erb` is loaded)
491
+ 1) **YAML configuration files**: `./config/<config-name>.yml`.
448
492
 
449
493
  In pure Ruby apps, we do not know about _environments_ (`test`, `development`, `production`, etc.); thus, we assume that the YAML contains values for a single environment:
450
494
 
@@ -453,9 +497,25 @@ host: localhost
453
497
  port: 3000
454
498
  ```
455
499
 
456
- **NOTE:** you can override the default YML lookup path by setting `MYCOOLGEM_CONF` env variable.
500
+ `ERB` is supported if `erb` is loaded (thus, you need to call `require "erb"` somewhere before loading configuration).
501
+
502
+ You can specify the lookup path for YAML files in one of the following ways:
503
+
504
+ - By setting `Anyway::Settings.default_config_path` to a target directory path:
505
+
506
+ ```ruby
507
+ Anyway::Settings.default_config_path = "/etc/configs"
508
+ ```
509
+
510
+ - By setting `Anyway::Settings.default_config_path` to a Proc, which accepts a config name and returns the path:
511
+
512
+ ```ruby
513
+ Anyway::Settings.default_config_path = ->(name) { Rails.root.join("data", "configs", "#{name}.yml") }
514
+ ```
515
+
516
+ - By overriding a specific config YML file path via the `<NAME>_CONF` env variable, e.g., `MYCOOLGEM_CONF=path/to/cool.yml`
457
517
 
458
- - `ENV['MYCOOLGEM_*']`.
518
+ 2) **Environment variables**: `ENV['MYCOOLGEM_*']`.
459
519
 
460
520
  See [environment variables](#environment-variables).
461
521
 
@@ -666,7 +726,7 @@ If you want to delete the env var, pass `nil` as the value.
666
726
 
667
727
  This helper is automatically included to RSpec if `RAILS_ENV` or `RACK_ENV` env variable is equal to "test". It's only available for the example with the tag `type: :config` or with the path `spec/configs/...`.
668
728
 
669
- You can add it manually by requiring `"anyway/testing/helpers"` and including the `Anyway::Test::Helpers` module (into RSpec configuration or Minitest test class).
729
+ You can add it manually by requiring `"anyway/testing/helpers"` and including the `Anyway::Testing::Helpers` module (into RSpec configuration or Minitest test class).
670
730
 
671
731
  ## OptionParser integration
672
732
 
@@ -0,0 +1,391 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "anyway/optparse_config"
4
+ require "anyway/dynamic_config"
5
+
6
+ module Anyway # :nodoc:
7
+ using RubyNext
8
+ using Anyway::Ext::DeepDup
9
+ using Anyway::Ext::DeepFreeze
10
+ using Anyway::Ext::Hash
11
+
12
+ using(Module.new do
13
+ refine Object do
14
+ def vm_object_id() = (object_id << 1).to_s(16)
15
+ end
16
+ end)
17
+
18
+ # Base config class
19
+ # Provides `attr_config` method to describe
20
+ # configuration parameters and set defaults
21
+ class Config
22
+ PARAM_NAME = /^[a-z_](\w+)?$/
23
+
24
+ # List of names that couldn't be used as config names
25
+ # (the class instance methods we use)
26
+ RESERVED_NAMES = %i[
27
+ config_name
28
+ env_prefix
29
+ values
30
+ class
31
+ clear
32
+ deconstruct_keys
33
+ dig
34
+ dup
35
+ initialize
36
+ load
37
+ load_from_sources
38
+ option_parser
39
+ pretty_print
40
+ raise_validation_error
41
+ reload
42
+ resolve_config_path
43
+ tap
44
+ to_h
45
+ to_source_trace
46
+ write_config_attr
47
+ ].freeze
48
+
49
+ class Error < StandardError; end
50
+
51
+ class ValidationError < Error; end
52
+
53
+ include OptparseConfig
54
+ include DynamicConfig
55
+
56
+ class BlockCallback
57
+ attr_reader :block
58
+
59
+ def initialize(block)
60
+ @block = block
61
+ end
62
+
63
+ def apply_to(config)
64
+ config.instance_exec(&block)
65
+ end
66
+ end
67
+
68
+ class NamedCallback
69
+ attr_reader :name
70
+
71
+ def initialize(name)
72
+ @name = name
73
+ end
74
+
75
+ def apply_to(config) = config.send(name)
76
+ end
77
+
78
+ class << self
79
+ def attr_config(*args, **hargs)
80
+ new_defaults = hargs.deep_dup
81
+ new_defaults.stringify_keys!
82
+
83
+ defaults.merge! new_defaults
84
+
85
+ new_keys = ((args + new_defaults.keys) - config_attributes)
86
+
87
+ validate_param_names! new_keys.map(&:to_s)
88
+
89
+ new_keys.map!(&:to_sym)
90
+
91
+ unless (reserved_names = (new_keys & RESERVED_NAMES)).empty?
92
+ raise ArgumentError, "Can not use the following reserved names as config attrubutes: " \
93
+ "#{reserved_names.sort.map(&:to_s).join(", ")}"
94
+ end
95
+
96
+ config_attributes.push(*new_keys)
97
+
98
+ define_config_accessor(*new_keys)
99
+
100
+ # Define predicate methods ("param?") for attributes
101
+ # having `true` or `false` as default values
102
+ new_defaults.each do |key, val|
103
+ next unless val.is_a?(TrueClass) || val.is_a?(FalseClass)
104
+ alias_method :"#{key}?", :"#{key}"
105
+ end
106
+ end
107
+
108
+ def defaults
109
+ return @defaults if instance_variable_defined?(:@defaults)
110
+
111
+ @defaults = if superclass < Anyway::Config
112
+ superclass.defaults.deep_dup
113
+ else
114
+ new_empty_config
115
+ end
116
+ end
117
+
118
+ def config_attributes
119
+ return @config_attributes if instance_variable_defined?(:@config_attributes)
120
+
121
+ @config_attributes = if superclass < Anyway::Config
122
+ superclass.config_attributes.dup
123
+ else
124
+ []
125
+ end
126
+ end
127
+
128
+ def required(*names)
129
+ unless (unknown_names = (names - config_attributes)).empty?
130
+ raise ArgumentError, "Unknown config param: #{unknown_names.join(",")}"
131
+ end
132
+
133
+ required_attributes.push(*names)
134
+ end
135
+
136
+ def required_attributes
137
+ return @required_attributes if instance_variable_defined?(:@required_attributes)
138
+
139
+ @required_attributes = if superclass < Anyway::Config
140
+ superclass.required_attributes.dup
141
+ else
142
+ []
143
+ end
144
+ end
145
+
146
+ def on_load(*names, &block)
147
+ raise ArgumentError, "Either methods or block should be specified, not both" if block && !names.empty?
148
+
149
+ if block
150
+ load_callbacks << BlockCallback.new(block)
151
+ else
152
+ load_callbacks.push(*names.map { NamedCallback.new(_1) })
153
+ end
154
+ end
155
+
156
+ def load_callbacks
157
+ return @load_callbacks if instance_variable_defined?(:@load_callbacks)
158
+
159
+ @load_callbacks = if superclass <= Anyway::Config
160
+ superclass.load_callbacks.dup
161
+ else
162
+ []
163
+ end
164
+ end
165
+
166
+ def config_name(val = nil)
167
+ return (@explicit_config_name = val.to_s) unless val.nil?
168
+
169
+ return @config_name if instance_variable_defined?(:@config_name)
170
+
171
+ @config_name = explicit_config_name || build_config_name
172
+ end
173
+
174
+ def explicit_config_name
175
+ return @explicit_config_name if instance_variable_defined?(:@explicit_config_name)
176
+
177
+ @explicit_config_name =
178
+ if superclass.respond_to?(:explicit_config_name)
179
+ superclass.explicit_config_name
180
+ end
181
+ end
182
+
183
+ def explicit_config_name?() = !explicit_config_name.nil?
184
+
185
+ def env_prefix(val = nil)
186
+ return (@env_prefix = val.to_s.upcase) unless val.nil?
187
+
188
+ return @env_prefix if instance_variable_defined?(:@env_prefix)
189
+
190
+ @env_prefix = if superclass < Anyway::Config && superclass.explicit_config_name?
191
+ superclass.env_prefix
192
+ else
193
+ config_name.upcase
194
+ end
195
+ end
196
+
197
+ def new_empty_config() = {}
198
+
199
+ private
200
+
201
+ def define_config_accessor(*names)
202
+ names.each do |name|
203
+ accessors_module.module_eval <<~RUBY, __FILE__, __LINE__ + 1
204
+ def #{name}=(val)
205
+ __trace__&.record_value(val, \"#{name}\", **Tracing.current_trace_source)
206
+ values[:#{name}] = val
207
+ end
208
+
209
+ def #{name}
210
+ values[:#{name}]
211
+ end
212
+ RUBY
213
+ end
214
+ end
215
+
216
+ def accessors_module
217
+ return @accessors_module if instance_variable_defined?(:@accessors_module)
218
+
219
+ @accessors_module = Module.new.tap do |mod|
220
+ include mod
221
+ end
222
+ end
223
+
224
+ def build_config_name
225
+ unless name
226
+ raise "Please, specify config name explicitly for anonymous class " \
227
+ "via `config_name :my_config`"
228
+ end
229
+
230
+ # handle two cases:
231
+ # - SomeModule::Config => "some_module"
232
+ # - SomeConfig => "some"
233
+ unless name =~ /^(\w+)(::)?Config$/
234
+ raise "Couldn't infer config name, please, specify it explicitly" \
235
+ "via `config_name :my_config`"
236
+ end
237
+
238
+ Regexp.last_match[1].tap(&:downcase!)
239
+ end
240
+
241
+ def validate_param_names!(names)
242
+ invalid_names = names.reject { |name| name =~ PARAM_NAME }
243
+ return if invalid_names.empty?
244
+
245
+ raise ArgumentError, "Invalid attr_config name: #{invalid_names.join(", ")}.\n" \
246
+ "Valid names must satisfy /#{PARAM_NAME.source}/."
247
+ end
248
+ end
249
+
250
+ on_load :validate_required_attributes!
251
+
252
+ attr_reader :config_name, :env_prefix
253
+
254
+ # Instantiate config instance.
255
+ #
256
+ # Example:
257
+ #
258
+ # my_config = Anyway::Config.new()
259
+ #
260
+ # # provide some values explicitly
261
+ # my_config = Anyway::Config.new({some: :value})
262
+ #
263
+ def initialize(overrides = nil)
264
+ @config_name = self.class.config_name
265
+
266
+ raise ArgumentError, "Config name is missing" unless @config_name
267
+
268
+ @env_prefix = self.class.env_prefix
269
+ @values = {}
270
+
271
+ load(overrides)
272
+ end
273
+
274
+ def reload(overrides = nil)
275
+ clear
276
+ load(overrides)
277
+ self
278
+ end
279
+
280
+ def clear
281
+ values.clear
282
+ @__trace__ = nil
283
+ self
284
+ end
285
+
286
+ def load(overrides = nil)
287
+ base_config = self.class.defaults.deep_dup
288
+
289
+ trace = Tracing.capture do
290
+ Tracing.trace!(:defaults) { base_config }
291
+
292
+ config_path = resolve_config_path(config_name, env_prefix)
293
+
294
+ load_from_sources(base_config, name: config_name, env_prefix: env_prefix, config_path: config_path)
295
+
296
+ if overrides
297
+ Tracing.trace!(:load) { overrides }
298
+
299
+ Utils.deep_merge!(base_config, overrides)
300
+ end
301
+ end
302
+
303
+ base_config.each do |key, val|
304
+ write_config_attr(key.to_sym, val)
305
+ end
306
+
307
+ # Trace may contain unknown attributes
308
+ trace&.keep_if { |key| self.class.config_attributes.include?(key.to_sym) }
309
+
310
+ # Run on_load callbacks
311
+ self.class.load_callbacks.each { _1.apply_to(self) }
312
+
313
+ # Set trace after we write all the values to
314
+ # avoid changing the source to accessor
315
+ @__trace__ = trace
316
+
317
+ self
318
+ end
319
+
320
+ def load_from_sources(base_config, **options)
321
+ Anyway.loaders.each do |(_id, loader)|
322
+ Utils.deep_merge!(base_config, loader.call(**options))
323
+ end
324
+ base_config
325
+ end
326
+
327
+ def dig(*keys) = values.dig(*keys)
328
+
329
+ def to_h() = values.deep_dup.deep_freeze
330
+
331
+ def dup
332
+ self.class.allocate.tap do |new_config|
333
+ %i[config_name env_prefix __trace__].each do |ivar|
334
+ new_config.instance_variable_set(:"@#{ivar}", send(ivar).dup)
335
+ end
336
+ new_config.instance_variable_set(:@values, values.deep_dup)
337
+ end
338
+ end
339
+
340
+ def resolve_config_path(name, env_prefix)
341
+ Anyway.env.fetch(env_prefix).delete("conf") || Settings.default_config_path.call(name)
342
+ end
343
+
344
+ def deconstruct_keys(keys) = values.deconstruct_keys(keys)
345
+
346
+ def to_source_trace() = __trace__&.to_h
347
+
348
+ def inspect
349
+ "#<#{self.class}:0x#{vm_object_id.rjust(16, "0")} config_name=\"#{config_name}\" env_prefix=\"#{env_prefix}\" " \
350
+ "values=#{values.inspect}>"
351
+ end
352
+
353
+ def pretty_print(q)
354
+ q.object_group self do
355
+ q.nest(1) do
356
+ q.breakable
357
+ q.text "config_name=#{config_name.inspect}"
358
+ q.breakable
359
+ q.text "env_prefix=#{env_prefix.inspect}"
360
+ q.breakable
361
+ q.text "values:"
362
+ q.pp __trace__
363
+ end
364
+ end
365
+ end
366
+
367
+ private
368
+
369
+ attr_reader :values, :__trace__
370
+
371
+ def validate_required_attributes!
372
+ self.class.required_attributes.select do |name|
373
+ values[name].nil? || (values[name].is_a?(String) && values[name].empty?)
374
+ end.then do |missing|
375
+ next if missing.empty?
376
+ raise_validation_error "The following config parameters are missing or empty: #{missing.join(", ")}"
377
+ end
378
+ end
379
+
380
+ def write_config_attr(key, val)
381
+ key = key.to_sym
382
+ return unless self.class.config_attributes.include?(key)
383
+
384
+ public_send(:"#{key}=", val)
385
+ end
386
+
387
+ def raise_validation_error(msg)
388
+ raise ValidationError, msg
389
+ end
390
+ end
391
+ end