anyway_config 2.8.1 → 2.9.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.
@@ -33,6 +33,7 @@ module Anyway # :nodoc:
33
33
  deconstruct_keys
34
34
  dig
35
35
  dup
36
+ format_unknown_attribute
36
37
  initialize
37
38
  load
38
39
  load_from_sources
@@ -52,6 +53,8 @@ module Anyway # :nodoc:
52
53
 
53
54
  class ValidationError < Error; end
54
55
 
56
+ class UnknownAttributesError < ValidationError; end
57
+
55
58
  include OptparseConfig
56
59
  include DynamicConfig
57
60
 
@@ -374,6 +377,8 @@ module Anyway # :nodoc:
374
377
  )
375
378
 
376
379
  if overrides
380
+ reject_unknown_config_attributes!(overrides, source: :overrides, strict: Settings.future.reject_unknown_overrides == true)
381
+
377
382
  Tracing.trace!(:load) { overrides }
378
383
 
379
384
  Utils.deep_merge!(base_config, overrides)
@@ -400,10 +405,16 @@ module Anyway # :nodoc:
400
405
  def load_from_sources(base_config, **opts)
401
406
  source_filter = self.class.configuration_sources
402
407
 
403
- Anyway.loaders.each do |(id, loader)|
408
+ Anyway.loaders.each do |(id, loader_or_proc)|
404
409
  next if source_filter && !source_filter.include?(id)
405
- Utils.deep_merge!(base_config, loader.call(**opts))
410
+
411
+ loader = (loader_or_proc.is_a?(Class) && loader_or_proc <= Loaders::Base) ? loader_or_proc.new(**opts) : loader_or_proc
412
+ loader_data = loader.call(**opts)
413
+ reject_unknown_config_attributes!(loader_data, source: id) if loader.is_a?(Loaders::Base) && loader.schemaful?
414
+
415
+ Utils.deep_merge!(base_config, loader_data)
406
416
  end
417
+
407
418
  base_config
408
419
  end
409
420
 
@@ -460,13 +471,50 @@ module Anyway # :nodoc:
460
471
 
461
472
  self.class.required_attributes.select do |name|
462
473
  val = values.dig(*name.to_s.split(".").map(&:to_sym))
463
- val.nil? || (val.is_a?(String) && val.empty?)
474
+ val.nil? || (val.respond_to?(:to_s) && val.to_s.empty?)
464
475
  end.then do |missing|
465
476
  next if missing.empty?
466
477
  raise_validation_error "The following config parameters for `#{self.class.name}(config_name: #{self.class.config_name})` are missing or empty: #{missing.join(", ")}"
467
478
  end
468
479
  end
469
480
 
481
+ def reject_unknown_config_attributes!(data, source:, strict: false)
482
+ return if data.nil? || data.empty?
483
+
484
+ mode = strict || Settings.enforce_config_schema
485
+ return unless mode
486
+
487
+ known = self.class.config_attributes.map(&:to_s)
488
+ unknown = data.keys.map(&:to_s) - known
489
+
490
+ unless strict
491
+ rescuer = Settings.rescue_unknown_attributes_with
492
+ unknown.reject! { |it| rescuer.call(self, it) } if rescuer
493
+ end
494
+
495
+ return if unknown.empty?
496
+
497
+ msg = "Unknown config attributes for `#{self.class.name || "<anonymous>"}(config_name: #{config_name})` " \
498
+ "provided by #{source}: #{unknown.map { |it| format_unknown_attribute(it, known) }.join(", ")}"
499
+
500
+ if strict
501
+ raise ArgumentError, msg
502
+ elsif mode == :warn
503
+ Kernel.warn msg
504
+ else
505
+ raise UnknownAttributesError, msg
506
+ end
507
+ end
508
+
509
+ def format_unknown_attribute(key, known)
510
+ return key unless defined?(::DidYouMean::SpellChecker)
511
+
512
+ suggestions = ::DidYouMean::SpellChecker.new(dictionary: known).correct(key)
513
+ return key if suggestions.empty?
514
+
515
+ "#{key} (did you mean: #{suggestions.join(", ")}?)"
516
+ end
517
+
470
518
  def write_config_attr(key, val)
471
519
  key = key.to_sym
472
520
  return unless self.class.config_attributes.include?(key)
@@ -11,11 +11,15 @@ module Anyway
11
11
  end
12
12
  end
13
13
 
14
- def initialize(local:)
14
+ def initialize(local: Anyway::Settings.use_local_files, **__kwrest__)
15
15
  @local = local
16
16
  end
17
17
 
18
18
  def use_local?() = @local == true
19
+
20
+ # Whether this loader supports strict schema (so we can warn or raise on
21
+ # unknown attributes)
22
+ def schemaful?() = false
19
23
  end
20
24
  end
21
25
  end
@@ -33,6 +33,7 @@ module Anyway # :nodoc:
33
33
  deconstruct_keys
34
34
  dig
35
35
  dup
36
+ format_unknown_attribute
36
37
  initialize
37
38
  load
38
39
  load_from_sources
@@ -52,6 +53,8 @@ module Anyway # :nodoc:
52
53
 
53
54
  class ValidationError < Error; end
54
55
 
56
+ class UnknownAttributesError < ValidationError; end
57
+
55
58
  include OptparseConfig
56
59
  include DynamicConfig
57
60
 
@@ -374,6 +377,8 @@ module Anyway # :nodoc:
374
377
  )
375
378
 
376
379
  if overrides
380
+ reject_unknown_config_attributes!(overrides, source: :overrides, strict: Settings.future.reject_unknown_overrides == true)
381
+
377
382
  Tracing.trace!(:load) { overrides }
378
383
 
379
384
  Utils.deep_merge!(base_config, overrides)
@@ -400,10 +405,16 @@ module Anyway # :nodoc:
400
405
  def load_from_sources(base_config, **opts)
401
406
  source_filter = self.class.configuration_sources
402
407
 
403
- Anyway.loaders.each do |(id, loader)|
408
+ Anyway.loaders.each do |(id, loader_or_proc)|
404
409
  next if source_filter && !source_filter.include?(id)
405
- Utils.deep_merge!(base_config, loader.call(**opts))
410
+
411
+ loader = (loader_or_proc.is_a?(Class) && loader_or_proc <= Loaders::Base) ? loader_or_proc.new(**opts) : loader_or_proc
412
+ loader_data = loader.call(**opts)
413
+ reject_unknown_config_attributes!(loader_data, source: id) if loader.is_a?(Loaders::Base) && loader.schemaful?
414
+
415
+ Utils.deep_merge!(base_config, loader_data)
406
416
  end
417
+
407
418
  base_config
408
419
  end
409
420
 
@@ -460,13 +471,50 @@ module Anyway # :nodoc:
460
471
 
461
472
  self.class.required_attributes.select do |name|
462
473
  val = values.dig(*name.to_s.split(".").map(&:to_sym))
463
- val.nil? || (val.is_a?(String) && val.empty?)
474
+ val.nil? || (val.respond_to?(:to_s) && val.to_s.empty?)
464
475
  end.then do |missing|
465
476
  next if missing.empty?
466
477
  raise_validation_error "The following config parameters for `#{self.class.name}(config_name: #{self.class.config_name})` are missing or empty: #{missing.join(", ")}"
467
478
  end
468
479
  end
469
480
 
481
+ def reject_unknown_config_attributes!(data, source:, strict: false)
482
+ return if data.nil? || data.empty?
483
+
484
+ mode = strict || Settings.enforce_config_schema
485
+ return unless mode
486
+
487
+ known = self.class.config_attributes.map(&:to_s)
488
+ unknown = data.keys.map(&:to_s) - known
489
+
490
+ unless strict
491
+ rescuer = Settings.rescue_unknown_attributes_with
492
+ unknown.reject! { |it| rescuer.call(self, it) } if rescuer
493
+ end
494
+
495
+ return if unknown.empty?
496
+
497
+ msg = "Unknown config attributes for `#{self.class.name || "<anonymous>"}(config_name: #{config_name})` " \
498
+ "provided by #{source}: #{unknown.map { |it| format_unknown_attribute(it, known) }.join(", ")}"
499
+
500
+ if strict
501
+ raise ArgumentError, msg
502
+ elsif mode == :warn
503
+ Kernel.warn msg
504
+ else
505
+ raise UnknownAttributesError, msg
506
+ end
507
+ end
508
+
509
+ def format_unknown_attribute(key, known)
510
+ return key unless defined?(::DidYouMean::SpellChecker)
511
+
512
+ suggestions = ::DidYouMean::SpellChecker.new(dictionary: known).correct(key)
513
+ return key if suggestions.empty?
514
+
515
+ "#{key} (did you mean: #{suggestions.join(", ")}?)"
516
+ end
517
+
470
518
  def write_config_attr(key, val)
471
519
  key = key.to_sym
472
520
  return unless self.class.config_attributes.include?(key)
data/lib/anyway/config.rb CHANGED
@@ -33,6 +33,7 @@ module Anyway # :nodoc:
33
33
  deconstruct_keys
34
34
  dig
35
35
  dup
36
+ format_unknown_attribute
36
37
  initialize
37
38
  load
38
39
  load_from_sources
@@ -52,6 +53,8 @@ module Anyway # :nodoc:
52
53
 
53
54
  class ValidationError < Error; end
54
55
 
56
+ class UnknownAttributesError < ValidationError; end
57
+
55
58
  include OptparseConfig
56
59
  include DynamicConfig
57
60
 
@@ -374,6 +377,8 @@ module Anyway # :nodoc:
374
377
  )
375
378
 
376
379
  if overrides
380
+ reject_unknown_config_attributes!(overrides, source: :overrides, strict: Settings.future.reject_unknown_overrides == true)
381
+
377
382
  Tracing.trace!(:load) { overrides }
378
383
 
379
384
  Utils.deep_merge!(base_config, overrides)
@@ -400,10 +405,16 @@ module Anyway # :nodoc:
400
405
  def load_from_sources(base_config, **opts)
401
406
  source_filter = self.class.configuration_sources
402
407
 
403
- Anyway.loaders.each do |(id, loader)|
408
+ Anyway.loaders.each do |(id, loader_or_proc)|
404
409
  next if source_filter && !source_filter.include?(id)
405
- Utils.deep_merge!(base_config, loader.call(**opts))
410
+
411
+ loader = (loader_or_proc.is_a?(Class) && loader_or_proc <= Loaders::Base) ? loader_or_proc.new(**opts) : loader_or_proc
412
+ loader_data = loader.call(**opts)
413
+ reject_unknown_config_attributes!(loader_data, source: id) if loader.is_a?(Loaders::Base) && loader.schemaful?
414
+
415
+ Utils.deep_merge!(base_config, loader_data)
406
416
  end
417
+
407
418
  base_config
408
419
  end
409
420
 
@@ -460,13 +471,50 @@ module Anyway # :nodoc:
460
471
 
461
472
  self.class.required_attributes.select do |name|
462
473
  val = values.dig(*name.to_s.split(".").map(&:to_sym))
463
- val.nil? || (val.is_a?(String) && val.empty?)
474
+ val.nil? || (val.respond_to?(:to_s) && val.to_s.empty?)
464
475
  end.then do |missing|
465
476
  next if missing.empty?
466
477
  raise_validation_error "The following config parameters for `#{self.class.name}(config_name: #{self.class.config_name})` are missing or empty: #{missing.join(", ")}"
467
478
  end
468
479
  end
469
480
 
481
+ def reject_unknown_config_attributes!(data, source:, strict: false)
482
+ return if data.nil? || data.empty?
483
+
484
+ mode = strict || Settings.enforce_config_schema
485
+ return unless mode
486
+
487
+ known = self.class.config_attributes.map(&:to_s)
488
+ unknown = data.keys.map(&:to_s) - known
489
+
490
+ unless strict
491
+ rescuer = Settings.rescue_unknown_attributes_with
492
+ unknown.reject! { rescuer.call(self, it) } if rescuer
493
+ end
494
+
495
+ return if unknown.empty?
496
+
497
+ msg = "Unknown config attributes for `#{self.class.name || "<anonymous>"}(config_name: #{config_name})` " \
498
+ "provided by #{source}: #{unknown.map { format_unknown_attribute(it, known) }.join(", ")}"
499
+
500
+ if strict
501
+ raise ArgumentError, msg
502
+ elsif mode == :warn
503
+ Kernel.warn msg
504
+ else
505
+ raise UnknownAttributesError, msg
506
+ end
507
+ end
508
+
509
+ def format_unknown_attribute(key, known)
510
+ return key unless defined?(::DidYouMean::SpellChecker)
511
+
512
+ suggestions = ::DidYouMean::SpellChecker.new(dictionary: known).correct(key)
513
+ return key if suggestions.empty?
514
+
515
+ "#{key} (did you mean: #{suggestions.join(", ")}?)"
516
+ end
517
+
470
518
  def write_config_attr(key, val)
471
519
  key = key.to_sym
472
520
  return unless self.class.config_attributes.include?(key)
@@ -11,11 +11,15 @@ module Anyway
11
11
  end
12
12
  end
13
13
 
14
- def initialize(local:)
14
+ def initialize(local: Anyway::Settings.use_local_files, **)
15
15
  @local = local
16
16
  end
17
17
 
18
18
  def use_local?() = @local == true
19
+
20
+ # Whether this loader supports strict schema (so we can warn or raise on
21
+ # unknown attributes)
22
+ def schemaful?() = false
19
23
  end
20
24
  end
21
25
  end
@@ -26,11 +26,14 @@ module Anyway
26
26
  config_hash = if ejson_namespace
27
27
  secrets_hash[ejson_namespace]
28
28
  else
29
- secrets_hash.except("_public_key")
29
+ secrets_hash
30
30
  end
31
31
 
32
32
  next unless config_hash.is_a?(Hash)
33
33
 
34
+ # Underscore-prefixed keys are not encrypted (e.g., _public_key)
35
+ config_hash = config_hash.reject { |key, _| key.start_with?("_") }
36
+
34
37
  configs <<
35
38
  trace!(:ejson, path: rel_path) do
36
39
  config_hash
@@ -44,6 +47,8 @@ module Anyway
44
47
  end
45
48
  end
46
49
 
50
+ def schemaful?() = true
51
+
47
52
  private
48
53
 
49
54
  def rel_config_paths
@@ -28,6 +28,8 @@ module Anyway
28
28
  Utils.deep_merge!(base_config, local_config)
29
29
  end
30
30
 
31
+ def schemaful?() = true
32
+
31
33
  private
32
34
 
33
35
  def environmental?(parsed_yml)
@@ -34,6 +34,8 @@ module Anyway
34
34
  config
35
35
  end
36
36
 
37
+ def schemaful?() = true
38
+
37
39
  private
38
40
 
39
41
  def local_credentials(name)
@@ -19,6 +19,8 @@ module Anyway
19
19
  config
20
20
  end
21
21
 
22
+ def schemaful?() = true
23
+
22
24
  private
23
25
 
24
26
  def secrets
data/lib/anyway/rails.rb CHANGED
@@ -12,7 +12,7 @@ require "anyway/rails/loaders"
12
12
  # Configure Rails loaders
13
13
  Anyway.loaders.override :yml, Anyway::Rails::Loaders::YAML
14
14
 
15
- if Rails::VERSION::MAJOR >= 7 && Rails::VERSION::MINOR >= 1
15
+ if Gem::Version.new(Rails.version) >= Gem::Version.new("7.1")
16
16
  Anyway.loaders.insert_after :yml, :credentials, Anyway::Rails::Loaders::Credentials
17
17
  else
18
18
  Anyway.loaders.insert_after :yml, :secrets, Anyway::Rails::Loaders::Secrets
@@ -38,6 +38,7 @@ module Anyway
38
38
  end
39
39
 
40
40
  setting :unwrap_known_environments, true
41
+ setting :reject_unknown_overrides, true
41
42
 
42
43
  private
43
44
 
@@ -55,6 +56,14 @@ module Anyway
55
56
  # Suppress required validations for CI/CD pipelines
56
57
  attr_accessor :suppress_required_validations
57
58
 
59
+ # Whether to check for unknown attributes in schemaful (key-based) sources:
60
+ # false — ignore unknown attributes, true — raise, :warn — print a warning
61
+ attr_accessor :enforce_config_schema
62
+
63
+ # A callable (config, key) -> bool to ignore specific unknown attributes
64
+ # (e.g., when the same source is shared by multiple configs)
65
+ attr_accessor :rescue_unknown_attributes_with
66
+
58
67
  # A proc returning a path to YML config file given the config name
59
68
  attr_reader :default_config_path
60
69
 
@@ -113,5 +122,8 @@ module Anyway
113
122
 
114
123
  # By default, use ANYWAY_SUPPRESS_VALIDATIONS
115
124
  self.suppress_required_validations = %w[1 t true y yes].include?(ENV["ANYWAY_SUPPRESS_VALIDATIONS"])
125
+
126
+ # Unknown attributes are ignored by default
127
+ self.enforce_config_schema = false
116
128
  end
117
129
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Anyway # :nodoc:
4
- VERSION = "2.8.1"
4
+ VERSION = "2.9.1"
5
5
  end
@@ -9,6 +9,10 @@ module Anyway
9
9
  def self.default_environmental_key: -> String?
10
10
  def self.suppress_required_validations: -> bool
11
11
  def self.known_environments: -> Array[String]?
12
+ def self.enforce_config_schema: -> (bool | :warn)
13
+ def self.enforce_config_schema=: (bool | :warn) -> void
14
+ def self.rescue_unknown_attributes_with: -> (^(Config, String) -> boolish)?
15
+ def self.rescue_unknown_attributes_with=: ((^(Config, String) -> boolish)?) -> void
12
16
 
13
17
  class Future
14
18
  def self.setting: (untyped name, untyped default_value) -> untyped
@@ -107,6 +111,8 @@ module Anyway
107
111
  private
108
112
  attr_reader values: Hash[untyped, untyped]
109
113
  def raise_validation_error: (String msg) -> void
114
+ def reject_unknown_config_attributes!: (Hash[untyped, untyped]? data, source: Symbol, ?strict: bool) -> void
115
+ def format_unknown_attribute: (String key, Array[String] known) -> String
110
116
  def flatten_hash: (Hash[untyped, untyped], String, Hash[String, String]) -> Hash[String, String]
111
117
 
112
118
  class Error < StandardError
@@ -114,6 +120,9 @@ module Anyway
114
120
 
115
121
  class ValidationError < Error
116
122
  end
123
+
124
+ class UnknownAttributesError < ValidationError
125
+ end
117
126
  end
118
127
 
119
128
  class Env
@@ -127,8 +136,10 @@ module Anyway
127
136
  include Tracing
128
137
 
129
138
  def self.call: (?local: bool, **untyped) -> untyped
130
- def initialize: (local: bool) -> void
139
+ def initialize: (?local: bool, **untyped) -> void
140
+ def call: (name: String, env_prefix: String, config_path: String | Pathname, **untyped) -> Hash[untyped, untyped]
131
141
  def use_local?: -> bool
142
+ def schemaful?: -> bool
132
143
  end
133
144
 
134
145
  interface _Loader