anyway_config 2.3.1 → 2.4.1

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.
@@ -8,6 +8,7 @@ module Anyway # :nodoc:
8
8
  using Anyway::Ext::DeepDup
9
9
  using Anyway::Ext::DeepFreeze
10
10
  using Anyway::Ext::Hash
11
+ using Anyway::Ext::FlattenNames
11
12
 
12
13
  using(Module.new do
13
14
  refine Object do
@@ -26,6 +27,7 @@ module Anyway # :nodoc:
26
27
  RESERVED_NAMES = %i[
27
28
  config_name
28
29
  env_prefix
30
+ as_env
29
31
  values
30
32
  class
31
33
  clear
@@ -47,8 +49,6 @@ module Anyway # :nodoc:
47
49
  __type_caster__
48
50
  ].freeze
49
51
 
50
- ENV_OPTION_EXCLUDE_KEY = :except
51
-
52
52
  class Error < StandardError; end
53
53
 
54
54
  class ValidationError < Error; end
@@ -128,34 +128,14 @@ module Anyway # :nodoc:
128
128
  end
129
129
  end
130
130
 
131
- def required(*names, env: nil)
132
- unknown_names = names - config_attributes
131
+ def required(*names, env: nil, **nested)
132
+ unknown_names = names + nested.keys - config_attributes
133
133
  raise ArgumentError, "Unknown config param: #{unknown_names.join(",")}" if unknown_names.any?
134
134
 
135
- names = filter_by_env(names, env)
136
- required_attributes.push(*names)
137
- end
138
-
139
- def filter_by_env(names, env)
140
- return names if env.nil? || env.to_s == current_env
141
-
142
- filtered_names = if env.is_a?(Hash)
143
- names_with_exclude_env_option(names, env)
144
- elsif env.is_a?(Array)
145
- names if env.flat_map(&:to_s).include?(current_env)
146
- end
147
-
148
- filtered_names || []
149
- end
150
-
151
- def current_env
152
- Settings.current_environment.to_s
153
- end
135
+ return unless Settings.matching_env?(env)
154
136
 
155
- def names_with_exclude_env_option(names, env)
156
- envs = env[ENV_OPTION_EXCLUDE_KEY]
157
- excluded_envs = [envs].flat_map(&:to_s)
158
- names if excluded_envs.none?(current_env)
137
+ required_attributes.push(*names)
138
+ required_attributes.push(*nested.flatten_names)
159
139
  end
160
140
 
161
141
  def required_attributes
@@ -219,10 +199,28 @@ module Anyway # :nodoc:
219
199
  end
220
200
  end
221
201
 
202
+ def loader_options(val = nil)
203
+ return (@loader_options = val) unless val.nil?
204
+
205
+ return @loader_options if instance_variable_defined?(:@loader_options)
206
+
207
+ @loader_options = if superclass < Anyway::Config
208
+ superclass.loader_options
209
+ else
210
+ {}
211
+ end
212
+ end
213
+
222
214
  def new_empty_config() = {}
223
215
 
224
216
  def coerce_types(mapping)
225
217
  Utils.deep_merge!(coercion_mapping, mapping)
218
+
219
+ mapping.each do |key, val|
220
+ next unless val == :boolean || (val.is_a?(::Hash) && val[:type] == :boolean)
221
+
222
+ alias_method :"#{key}?", :"#{key}"
223
+ end
226
224
  end
227
225
 
228
226
  def coercion_mapping
@@ -268,7 +266,7 @@ module Anyway # :nodoc:
268
266
  names.each do |name|
269
267
  accessors_module.module_eval <<~RUBY, __FILE__, __LINE__ + 1
270
268
  def #{name}=(val)
271
- __trace__&.record_value(val, \"#{name}\", **Tracing.current_trace_source)
269
+ __trace__&.record_value(val, "#{name}", **Tracing.current_trace_source)
272
270
  values[:#{name}] = val
273
271
  end
274
272
 
@@ -357,7 +355,13 @@ module Anyway # :nodoc:
357
355
 
358
356
  config_path = resolve_config_path(config_name, env_prefix)
359
357
 
360
- load_from_sources(base_config, name: config_name, env_prefix: env_prefix, config_path: config_path)
358
+ load_from_sources(
359
+ base_config,
360
+ name: config_name,
361
+ env_prefix: env_prefix,
362
+ config_path: config_path,
363
+ **self.class.loader_options
364
+ )
361
365
 
362
366
  if overrides
363
367
  Tracing.trace!(:load) { overrides }
@@ -430,13 +434,18 @@ module Anyway # :nodoc:
430
434
  end
431
435
  end
432
436
 
437
+ def as_env
438
+ Env.from_hash(to_h, prefix: env_prefix)
439
+ end
440
+
433
441
  private
434
442
 
435
443
  attr_reader :values, :__trace__
436
444
 
437
445
  def validate_required_attributes!
438
446
  self.class.required_attributes.select do |name|
439
- values[name].nil? || (values[name].is_a?(String) && values[name].empty?)
447
+ val = values.dig(*name.to_s.split(".").map(&:to_sym))
448
+ val.nil? || (val.is_a?(String) && val.empty?)
440
449
  end.then do |missing|
441
450
  next if missing.empty?
442
451
  raise_validation_error "The following config parameters for `#{self.class.name}(config_name: #{self.class.config_name})` are missing or empty: #{missing.join(", ")}"
@@ -8,14 +8,31 @@ module Anyway
8
8
  using Anyway::Ext::DeepDup
9
9
  using Anyway::Ext::Hash
10
10
 
11
+ class << self
12
+ def from_hash(hash, prefix: nil, memo: {})
13
+ hash.each do |key, value|
14
+ prefix_with_key = (prefix && !prefix.empty?) ? "#{prefix}_#{key.to_s.upcase}" : key.to_s.upcase
15
+
16
+ if value.is_a?(Hash)
17
+ from_hash(value, prefix: "#{prefix_with_key}_", memo: memo)
18
+ else
19
+ memo[prefix_with_key] = value.to_s
20
+ end
21
+ end
22
+
23
+ memo
24
+ end
25
+ end
26
+
11
27
  include Tracing
12
28
 
13
- attr_reader :data, :traces, :type_cast
29
+ attr_reader :data, :traces, :type_cast, :env_container
14
30
 
15
- def initialize(type_cast: AutoCast)
31
+ def initialize(type_cast: AutoCast, env_container: ENV)
16
32
  @type_cast = type_cast
17
33
  @data = {}
18
34
  @traces = {}
35
+ @env_container = env_container
19
36
  end
20
37
 
21
38
  def clear
@@ -42,11 +59,11 @@ module Anyway
42
59
  private
43
60
 
44
61
  def parse_env(prefix)
45
- match_prefix = "#{prefix}_"
46
- ENV.each_pair.with_object({}) do |(key, val), data|
62
+ match_prefix = prefix.empty? ? prefix : "#{prefix}_"
63
+ env_container.each_pair.with_object({}) do |(key, val), data|
47
64
  next unless key.start_with?(match_prefix)
48
65
 
49
- path = key.sub(/^#{prefix}_/, "").downcase
66
+ path = key.sub(/^#{match_prefix}/, "").downcase
50
67
 
51
68
  paths = path.split("__")
52
69
  trace!(:env, *paths, key: key) { data.bury(type_cast.call(val), *paths) }
@@ -19,7 +19,7 @@ module Anyway
19
19
  def initialize(type = :trace, value = UNDEF, **source)
20
20
  @type = type
21
21
  @source = source
22
- @value = value == UNDEF ? Hash.new { |h, k| h[k] = Trace.new(:trace) } : value
22
+ @value = (value == UNDEF) ? Hash.new { |h, k| h[k] = Trace.new(:trace) } : value
23
23
  end
24
24
 
25
25
  def dig(...)
@@ -35,7 +35,7 @@ module Anyway
35
35
  end
36
36
 
37
37
  target_trace = path.empty? ? self : value.dig(*path)
38
- target_trace.value[key.to_s] = trace
38
+ target_trace.record_key(key.to_s, trace)
39
39
 
40
40
  val
41
41
  end
@@ -54,6 +54,12 @@ module Anyway
54
54
  hash
55
55
  end
56
56
 
57
+ def record_key(key, key_trace)
58
+ @value = Hash.new { |h, k| h[k] = Trace.new(:trace) } unless value.is_a?(::Hash)
59
+
60
+ value[key] = key_trace
61
+ end
62
+
57
63
  def merge!(another_trace)
58
64
  raise ArgumentError, "You can only merge into a :trace type, and this is :#{type}" unless trace?
59
65
  raise ArgumentError, "You can only merge a :trace type, but trying :#{type}" unless another_trace.trace?
data/lib/anyway/config.rb CHANGED
@@ -8,6 +8,7 @@ module Anyway # :nodoc:
8
8
  using Anyway::Ext::DeepDup
9
9
  using Anyway::Ext::DeepFreeze
10
10
  using Anyway::Ext::Hash
11
+ using Anyway::Ext::FlattenNames
11
12
 
12
13
  using(Module.new do
13
14
  refine Object do
@@ -26,6 +27,7 @@ module Anyway # :nodoc:
26
27
  RESERVED_NAMES = %i[
27
28
  config_name
28
29
  env_prefix
30
+ as_env
29
31
  values
30
32
  class
31
33
  clear
@@ -47,8 +49,6 @@ module Anyway # :nodoc:
47
49
  __type_caster__
48
50
  ].freeze
49
51
 
50
- ENV_OPTION_EXCLUDE_KEY = :except
51
-
52
52
  class Error < StandardError; end
53
53
 
54
54
  class ValidationError < Error; end
@@ -128,34 +128,14 @@ module Anyway # :nodoc:
128
128
  end
129
129
  end
130
130
 
131
- def required(*names, env: nil)
132
- unknown_names = names - config_attributes
131
+ def required(*names, env: nil, **nested)
132
+ unknown_names = names + nested.keys - config_attributes
133
133
  raise ArgumentError, "Unknown config param: #{unknown_names.join(",")}" if unknown_names.any?
134
134
 
135
- names = filter_by_env(names, env)
136
- required_attributes.push(*names)
137
- end
138
-
139
- def filter_by_env(names, env)
140
- return names if env.nil? || env.to_s == current_env
141
-
142
- filtered_names = if env.is_a?(Hash)
143
- names_with_exclude_env_option(names, env)
144
- elsif env.is_a?(Array)
145
- names if env.flat_map(&:to_s).include?(current_env)
146
- end
147
-
148
- filtered_names || []
149
- end
150
-
151
- def current_env
152
- Settings.current_environment.to_s
153
- end
135
+ return unless Settings.matching_env?(env)
154
136
 
155
- def names_with_exclude_env_option(names, env)
156
- envs = env[ENV_OPTION_EXCLUDE_KEY]
157
- excluded_envs = [envs].flat_map(&:to_s)
158
- names if excluded_envs.none?(current_env)
137
+ required_attributes.push(*names)
138
+ required_attributes.push(*nested.flatten_names)
159
139
  end
160
140
 
161
141
  def required_attributes
@@ -219,10 +199,28 @@ module Anyway # :nodoc:
219
199
  end
220
200
  end
221
201
 
202
+ def loader_options(val = nil)
203
+ return (@loader_options = val) unless val.nil?
204
+
205
+ return @loader_options if instance_variable_defined?(:@loader_options)
206
+
207
+ @loader_options = if superclass < Anyway::Config
208
+ superclass.loader_options
209
+ else
210
+ {}
211
+ end
212
+ end
213
+
222
214
  def new_empty_config() = {}
223
215
 
224
216
  def coerce_types(mapping)
225
217
  Utils.deep_merge!(coercion_mapping, mapping)
218
+
219
+ mapping.each do |key, val|
220
+ next unless val == :boolean || (val.is_a?(::Hash) && val[:type] == :boolean)
221
+
222
+ alias_method :"#{key}?", :"#{key}"
223
+ end
226
224
  end
227
225
 
228
226
  def coercion_mapping
@@ -268,7 +266,7 @@ module Anyway # :nodoc:
268
266
  names.each do |name|
269
267
  accessors_module.module_eval <<~RUBY, __FILE__, __LINE__ + 1
270
268
  def #{name}=(val)
271
- __trace__&.record_value(val, \"#{name}\", **Tracing.current_trace_source)
269
+ __trace__&.record_value(val, "#{name}", **Tracing.current_trace_source)
272
270
  values[:#{name}] = val
273
271
  end
274
272
 
@@ -357,7 +355,13 @@ module Anyway # :nodoc:
357
355
 
358
356
  config_path = resolve_config_path(config_name, env_prefix)
359
357
 
360
- load_from_sources(base_config, name: config_name, env_prefix:, config_path:)
358
+ load_from_sources(
359
+ base_config,
360
+ name: config_name,
361
+ env_prefix:,
362
+ config_path:,
363
+ **self.class.loader_options
364
+ )
361
365
 
362
366
  if overrides
363
367
  Tracing.trace!(:load) { overrides }
@@ -430,13 +434,18 @@ module Anyway # :nodoc:
430
434
  end
431
435
  end
432
436
 
437
+ def as_env
438
+ Env.from_hash(to_h, prefix: env_prefix)
439
+ end
440
+
433
441
  private
434
442
 
435
443
  attr_reader :values, :__trace__
436
444
 
437
445
  def validate_required_attributes!
438
446
  self.class.required_attributes.select do |name|
439
- values[name].nil? || (values[name].is_a?(String) && values[name].empty?)
447
+ val = values.dig(*name.to_s.split(".").map(&:to_sym))
448
+ val.nil? || (val.is_a?(String) && val.empty?)
440
449
  end.then do |missing|
441
450
  next if missing.empty?
442
451
  raise_validation_error "The following config parameters for `#{self.class.name}(config_name: #{self.class.config_name})` are missing or empty: #{missing.join(", ")}"
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "anyway/ext/hash"
5
+
6
+ using Anyway::Ext::Hash
7
+
8
+ module Anyway
9
+ class EJSONParser
10
+ attr_reader :bin_path
11
+
12
+ def initialize(bin_path = "ejson")
13
+ @bin_path = bin_path
14
+ end
15
+
16
+ def call(file_path)
17
+ return unless File.exist?(file_path)
18
+
19
+ raw_content = nil
20
+
21
+ stdout, stderr, status = Open3.capture3("#{bin_path} decrypt #{file_path}")
22
+
23
+ if status.success?
24
+ raw_content = JSON.parse(stdout.chomp)
25
+ else
26
+ Kernel.warn "Failed to decrypt #{file_path}: #{stderr}"
27
+ end
28
+
29
+ return unless raw_content
30
+
31
+ raw_content.deep_transform_keys do |key|
32
+ if key[0] == "_"
33
+ key[1..]
34
+ else
35
+ key
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
data/lib/anyway/env.rb CHANGED
@@ -8,14 +8,31 @@ module Anyway
8
8
  using Anyway::Ext::DeepDup
9
9
  using Anyway::Ext::Hash
10
10
 
11
+ class << self
12
+ def from_hash(hash, prefix: nil, memo: {})
13
+ hash.each do |key, value|
14
+ prefix_with_key = (prefix && !prefix.empty?) ? "#{prefix}_#{key.to_s.upcase}" : key.to_s.upcase
15
+
16
+ if value.is_a?(Hash)
17
+ from_hash(value, prefix: "#{prefix_with_key}_", memo:)
18
+ else
19
+ memo[prefix_with_key] = value.to_s
20
+ end
21
+ end
22
+
23
+ memo
24
+ end
25
+ end
26
+
11
27
  include Tracing
12
28
 
13
- attr_reader :data, :traces, :type_cast
29
+ attr_reader :data, :traces, :type_cast, :env_container
14
30
 
15
- def initialize(type_cast: AutoCast)
31
+ def initialize(type_cast: AutoCast, env_container: ENV)
16
32
  @type_cast = type_cast
17
33
  @data = {}
18
34
  @traces = {}
35
+ @env_container = env_container
19
36
  end
20
37
 
21
38
  def clear
@@ -42,11 +59,11 @@ module Anyway
42
59
  private
43
60
 
44
61
  def parse_env(prefix)
45
- match_prefix = "#{prefix}_"
46
- ENV.each_pair.with_object({}) do |(key, val), data|
62
+ match_prefix = prefix.empty? ? prefix : "#{prefix}_"
63
+ env_container.each_pair.with_object({}) do |(key, val), data|
47
64
  next unless key.start_with?(match_prefix)
48
65
 
49
- path = key.sub(/^#{prefix}_/, "").downcase
66
+ path = key.sub(/^#{match_prefix}/, "").downcase
50
67
 
51
68
  paths = path.split("__")
52
69
  trace!(:env, *paths, key:) { data.bury(type_cast.call(val), *paths) }
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anyway
4
+ module Ext
5
+ # Convert Hash with mixed array and hash values to an
6
+ # array of paths.
7
+ module FlattenNames
8
+ refine ::Array do
9
+ def flatten_names(prefix, buf)
10
+ if empty?
11
+ buf << :"#{prefix}"
12
+ return buf
13
+ end
14
+
15
+ each_with_object(buf) do |name, acc|
16
+ if name.is_a?(::Symbol)
17
+ acc << :"#{prefix}.#{name}"
18
+ else
19
+ name.flatten_names(prefix, acc)
20
+ end
21
+ end
22
+ end
23
+ end
24
+
25
+ refine ::Hash do
26
+ def flatten_names(prefix = nil, buf = [])
27
+ each_with_object(buf) do |(k, v), acc|
28
+ parent = prefix ? "#{prefix}.#{k}" : k
29
+ v.flatten_names(parent, acc)
30
+ end
31
+ end
32
+ end
33
+
34
+ using self
35
+ end
36
+ end
37
+ end
@@ -21,11 +21,18 @@ module Anyway
21
21
 
22
22
  last_key = path.pop
23
23
  hash = path.reduce(self) do |hash, k|
24
- hash[k] = {} unless hash.key?(k)
24
+ hash[k] = {} unless hash.key?(k) && hash[k].is_a?(::Hash)
25
25
  hash[k]
26
26
  end
27
+
27
28
  hash[last_key] = val
28
29
  end
30
+
31
+ def deep_transform_keys(&block)
32
+ each_with_object({}) do |(key, value), result|
33
+ result[yield(key)] = value.is_a?(::Hash) ? value.deep_transform_keys(&block) : value
34
+ end
35
+ end
29
36
  end
30
37
 
31
38
  using self
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anyway
4
+ module Ext
5
+ # Add simple safe_constantize method to String
6
+ module StringConstantize
7
+ refine String do
8
+ def safe_constantize
9
+ names = split("::")
10
+
11
+ return nil if names.empty?
12
+
13
+ # Remove the first blank element in case of '::ClassName' notation.
14
+ names.shift if names.size > 1 && names.first.empty?
15
+
16
+ names.inject(Object) do |constant, name|
17
+ break if constant.nil?
18
+ constant.const_get(name, false) if constant.const_defined?(name, false)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "net/http"
5
+ require "json"
6
+
7
+ module Anyway
8
+ using RubyNext
9
+
10
+ module Loaders
11
+ class Doppler < Base
12
+ class RequestError < StandardError; end
13
+
14
+ class << self
15
+ attr_accessor :download_url
16
+ attr_writer :token
17
+
18
+ def token
19
+ @token || ENV["DOPPLER_TOKEN"]
20
+ end
21
+ end
22
+
23
+ self.download_url = "https://api.doppler.com/v3/configs/config/secrets/download"
24
+
25
+ def call(env_prefix:, **_options)
26
+ env_payload = parse_doppler_response(url: Doppler.download_url, token: Doppler.token)
27
+
28
+ env = ::Anyway::Env.new(type_cast: ::Anyway::NoCast, env_container: env_payload)
29
+
30
+ env.fetch_with_trace(env_prefix).then do |(conf, trace)|
31
+ Tracing.current_trace&.merge!(trace)
32
+ conf
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def parse_doppler_response(url:, token:)
39
+ response = fetch_doppler_config(url, token)
40
+
41
+ unless response.is_a?(Net::HTTPSuccess)
42
+ raise RequestError, "#{response.code} #{response.message}"
43
+ end
44
+
45
+ JSON.parse(response.read_body)
46
+ end
47
+
48
+ def fetch_doppler_config(url, token)
49
+ uri = URI.parse(url)
50
+ raise "Doppler token is required to load configuration from Doppler" if token.nil?
51
+
52
+ http = Net::HTTP.new(uri.host, uri.port)
53
+ http.use_ssl = true if uri.scheme == "https"
54
+
55
+ request = Net::HTTP::Get.new(uri)
56
+ request["Accept"] = "application/json"
57
+ request["Authorization"] = "Bearer #{token}"
58
+
59
+ http.request(request)
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "anyway/ejson_parser"
4
+
5
+ module Anyway
6
+ module Loaders
7
+ class EJSON < Base
8
+ class << self
9
+ attr_accessor :bin_path
10
+ end
11
+
12
+ self.bin_path = "ejson"
13
+
14
+ def call(name:, ejson_namespace: name, ejson_parser: Anyway::EJSONParser.new(EJSON.bin_path), **_options)
15
+ configs = []
16
+
17
+ rel_config_paths.each do |rel_config_path|
18
+ secrets_hash, rel_path =
19
+ extract_hash_from_rel_config_path(
20
+ ejson_parser: ejson_parser,
21
+ rel_config_path: rel_config_path
22
+ )
23
+
24
+ next unless secrets_hash
25
+
26
+ config_hash = if ejson_namespace
27
+ secrets_hash[ejson_namespace]
28
+ else
29
+ secrets_hash.except("_public_key")
30
+ end
31
+
32
+ next unless config_hash.is_a?(Hash)
33
+
34
+ configs <<
35
+ trace!(:ejson, path: rel_path) do
36
+ config_hash
37
+ end
38
+ end
39
+
40
+ return {} if configs.empty?
41
+
42
+ configs.inject do |result_config, next_config|
43
+ Utils.deep_merge!(result_config, next_config)
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def rel_config_paths
50
+ chain = [environmental_rel_config_path]
51
+
52
+ chain << "secrets.local.ejson" if use_local?
53
+
54
+ chain
55
+ end
56
+
57
+ def environmental_rel_config_path
58
+ if Settings.current_environment
59
+ # if environment file is absent, then take data from the default one
60
+ [
61
+ "#{Settings.current_environment}/secrets.ejson",
62
+ default_rel_config_path
63
+ ]
64
+ else
65
+ default_rel_config_path
66
+ end
67
+ end
68
+
69
+ def default_rel_config_path
70
+ "secrets.ejson"
71
+ end
72
+
73
+ def extract_hash_from_rel_config_path(ejson_parser:, rel_config_path:)
74
+ rel_config_path = [rel_config_path] unless rel_config_path.is_a?(Array)
75
+
76
+ rel_config_path.each do |rel_conf_path|
77
+ rel_path = "config/#{rel_conf_path}"
78
+ abs_path = "#{Settings.app_root}/#{rel_path}"
79
+
80
+ result = ejson_parser.call(abs_path)
81
+
82
+ return [result, rel_path] if result
83
+ end
84
+
85
+ nil
86
+ end
87
+ end
88
+ end
89
+ end
@@ -75,3 +75,5 @@ end
75
75
  require "anyway/loaders/base"
76
76
  require "anyway/loaders/yaml"
77
77
  require "anyway/loaders/env"
78
+ require "anyway/loaders/doppler"
79
+ require "anyway/loaders/ejson"