anyway_config 2.8.1 → 2.9.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c2505731c8729ea6a097cef8718c441684124da047a6856bd6156b4592c1ad9b
4
- data.tar.gz: d20b15281220f9c3d77b7440ffcf915db6b28e6c7555ecec5ac9e4583de34887
3
+ metadata.gz: 910bf1014d5b1b2a7952b2e6da131a341722998f3b891d096f4d5764006fac23
4
+ data.tar.gz: 6f9233771e022ccb6d3ea3d03ee2b15bee8e2e3d322aa3434cd5d08b8794cf14
5
5
  SHA512:
6
- metadata.gz: 70db78ae8a9859a4f660173101127d4e8d30c6a059f5b3cba121864be120a787a5998f740a948651f84c82cb22e87f7bce7926a4ad6b27352297dbc9f24d20b4
7
- data.tar.gz: a314943f0a1fe0779235a75656d80cbf7a1525bbd6db24ab4fe15f508d4ac3c2790819a6d9abf39682c17c0bb0fadf4184976e9a9753dc3d61f594b910bd211a
6
+ metadata.gz: 11f0d4f61ebabda431abba36837438c767405a0432e2f8f8ae1d2460b35e8e14438b9ed2acb6cb9d2d1b6889111e61cfecdf7abed2e2d4bc40eccba416c1d6f5
7
+ data.tar.gz: 3bf99f8766ed1a38ea3d9b6f114bf59c694c492ecd0f635067b97750750c2afea3e4d3c476e4d7bfa70c6e02fb478e36f273a4b32613712c79e10dc7c9fed400
data/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## master
4
4
 
5
+ ## 2.9.0 (2026-10-20)
6
+
7
+ - Add a new `enforce_config_schema` to warn or raise on unknown attributes. ([@palkan][])
8
+
9
+ When the schema is enforced, the config attributes defined in the key-based sources (YAML, credentials, etc.) must correspond to `attr_config` names in the corresponding class. Providing an unknown attribute (e.g., a typo) would raise or print a warning depending on the parameter value:
10
+
11
+ ```ruby
12
+ Anyway::Settings.enforce_config_schema = true # or :warn
13
+
14
+ # in Rails
15
+ config.anyway_config.enforce_config_schema = true
16
+ ```
17
+
18
+ - EJSON loader now ignores underscore-prefixed (unencrypted) keys. ([@palkan][])
19
+
5
20
  ## 2.8.1 (2026-07-22)
6
21
 
7
22
  - Fix default loaders order to keep ENV the last one (=higher precedence). ([@palkan][])
data/README.md CHANGED
@@ -22,8 +22,9 @@ For application developers, Anyway Config could be useful to:
22
22
  **NOTE:** this readme shows documentation for 2.x version.
23
23
  For version 1.x see the [1-4-stable branch](https://github.com/palkan/anyway_config/tree/1-4-stable).
24
24
 
25
- <a href="https://evilmartians.com/?utm_source=anyway_config">
26
- <img src="https://evilmartians.com/badges/sponsored-by-evil-martians.svg" alt="Sponsored by Evil Martians" width="236" height="54"></a>
25
+ <br/>
26
+
27
+ <img src="https://cdn.evilmartians.com/badges/logo-no-label.svg" alt="Evil Martians logo" width="22" height="16" /> <b>Anyway Config</b> is built by <b><a href="https://evilmartians.com/">Evil Martians</a></b>, an American design and engineering consultancy for <b>developer tools, AI, and cybersecurity startups</b>.
27
28
 
28
29
  ## Links
29
30
 
@@ -37,6 +38,7 @@ For version 1.x see the [1-4-stable branch](https://github.com/palkan/anyway_con
37
38
  - [Configuration classes](#configuration-classes)
38
39
  - [Dynamic configuration](#dynamic-configuration)
39
40
  - [Validation & Callbacks](#validation-and-callbacks)
41
+ - [Enforcing config schema](#enforcing-config-schema)
40
42
  - [Using with Rails applications](#using-with-rails)
41
43
  - [Data population](#data-population)
42
44
  - [Organizing configs](#organizing-configs)
@@ -345,6 +347,45 @@ class MyConfig < Anyway::Config
345
347
  end
346
348
  ```
347
349
 
350
+ ### Enforcing config schema
351
+
352
+ By default, Anyway Config ignores keys that don't match any of the config attributes. Thus, a typo in a YAML file (e.g., `hots` instead of `host`) goes unnoticed. You can make Anyway Config check for unknown attributes via the `enforce_config_schema` setting:
353
+
354
+ ```ruby
355
+ # Raise Anyway::Config::UnknownAttributesError
356
+ Anyway::Settings.enforce_config_schema = true
357
+
358
+ # Print a warning instead
359
+ Anyway::Settings.enforce_config_schema = :warn
360
+
361
+ # Or in Rails
362
+ config.anyway_config.enforce_config_schema = true
363
+ ```
364
+
365
+ The schema is only enforced for key-based (_schemaful_) sources: YAML files, Rails credentials and secrets, and EJSON. Environment-based sources (ENV, Doppler) are never checked, since env prefixes can easily collide with unrelated variables (e.g., `AWS_PROFILE` for the `aws` config).
366
+
367
+ Only top-level keys are checked; nested hashes are not validated.
368
+
369
+ #### Allowing unknown attributes for some configs
370
+
371
+ You can use the `rescue_unknown_attributes_with` configuration parameter to ignore specific attributes. It's called for every unknown attribute with the config object and the attribute name; return a truthy value to skip (allow) it:
372
+
373
+ ```ruby
374
+ Anyway::Settings.rescue_unknown_attributes_with = ->(config, key) do
375
+ config.config_name == "app"
376
+ end
377
+ ```
378
+
379
+ This is particularly useful when you have multiple config classes associated with the same configurable entity (say, a service provider).
380
+
381
+ #### Unknown explicit values
382
+
383
+ Unknown attributes passed explicitly (`MyConfig.new(hots: "localhost")`) will raise disregards the `enforce_config_schema` setting in the future. Currently, they follow the `enforce_config_schema` configuration but we recommend to opt-in for the stricter future behaviour:
384
+
385
+ ```ruby
386
+ Anyway::Settings.future.use :reject_unknown_overrides
387
+ ```
388
+
348
389
  ## Using with Rails
349
390
 
350
391
  **NOTE:** version 2.x supports Rails >= 6.0; for older versions use version 1.x of the gem.
@@ -825,6 +866,8 @@ class MyConfig < Anyway::Config
825
866
  end
826
867
  ```
827
868
 
869
+ Underscore-prefixed keys (which are not encrypted by EJSON, e.g., `_public_key`) are ignored.
870
+
828
871
  **NOTE:** You can opt-out from EJSON loader by specifying the `ANYWAY_CONFIG_DISABLE_EJSON=true` env var (in case you have the `ejson` executable in your PATH, but don't want to use it with Anyway Config).
829
872
 
830
873
  ### Custom loaders
@@ -865,6 +908,9 @@ class ChamberConfigLoader < Base
865
908
  warn "Couldn't decrypt Chamber settings: #{e.message}"
866
909
  {}
867
910
  end
911
+
912
+ # Report unknown keys if enforce_config_schema is enabled
913
+ def schemaful? = true
868
914
  end
869
915
 
870
916
  # Don't forget to register it
@@ -28,6 +28,8 @@ module Anyway
28
28
  Utils.deep_merge!(base_config, local_config)
29
29
  end
30
30
 
31
+ def schemaful?() ; true; end
32
+
31
33
  private
32
34
 
33
35
  def environmental?(parsed_yml)
@@ -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
@@ -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
 
@@ -467,6 +478,43 @@ module Anyway # :nodoc:
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; end
19
+
20
+ # Whether this loader supports strict schema (so we can warn or raise on
21
+ # unknown attributes)
22
+ def schemaful?() ; false; end
19
23
  end
20
24
  end
21
25
  end
@@ -0,0 +1,94 @@
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
30
+ end
31
+
32
+ next unless config_hash.is_a?(Hash)
33
+
34
+ # Underscore-prefixed keys are not encrypted (e.g., _public_key)
35
+ config_hash = config_hash.reject { |key, _| key.start_with?("_") }
36
+
37
+ configs <<
38
+ trace!(:ejson, path: rel_path) do
39
+ config_hash
40
+ end
41
+ end
42
+
43
+ return {} if configs.empty?
44
+
45
+ configs.inject do |result_config, next_config|
46
+ Utils.deep_merge!(result_config, next_config)
47
+ end
48
+ end
49
+
50
+ def schemaful?() ; true; end
51
+
52
+ private
53
+
54
+ def rel_config_paths
55
+ chain = [environmental_rel_config_path]
56
+
57
+ chain << "secrets.local.ejson" if use_local?
58
+
59
+ chain
60
+ end
61
+
62
+ def environmental_rel_config_path
63
+ if Settings.current_environment
64
+ # if environment file is absent, then take data from the default one
65
+ [
66
+ "#{Settings.current_environment}/secrets.ejson",
67
+ default_rel_config_path
68
+ ]
69
+ else
70
+ default_rel_config_path
71
+ end
72
+ end
73
+
74
+ def default_rel_config_path
75
+ "secrets.ejson"
76
+ end
77
+
78
+ def extract_hash_from_rel_config_path(ejson_parser:, rel_config_path:)
79
+ rel_config_path = [rel_config_path] unless rel_config_path.is_a?(Array)
80
+
81
+ rel_config_path.each do |rel_conf_path|
82
+ rel_path = "config/#{rel_conf_path}"
83
+ abs_path = "#{Settings.app_root}/#{rel_path}"
84
+
85
+ result = ejson_parser.call(abs_path)
86
+
87
+ return [result, rel_path] if result
88
+ end
89
+
90
+ nil
91
+ end
92
+ end
93
+ end
94
+ end
@@ -28,6 +28,8 @@ module Anyway
28
28
  Utils.deep_merge!(base_config, local_config)
29
29
  end
30
30
 
31
+ def schemaful?() ; true; end
32
+
31
33
  private
32
34
 
33
35
  def environmental?(parsed_yml)
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anyway
4
+ module Rails
5
+ module Loaders
6
+ class Credentials < Anyway::Loaders::Base
7
+ LOCAL_CONTENT_PATH = "config/credentials/local.yml.enc"
8
+
9
+ def call(name:, **_options)
10
+ return {} unless ::Rails.application.respond_to?(:credentials)
11
+
12
+ # do not load from credentials if we're in the context
13
+ # of the `credentials:edit` command
14
+ return {} if defined?(::Rails::Command::CredentialsCommand)
15
+
16
+ # Create a new hash cause credentials are mutable!
17
+ config = {}
18
+
19
+ trace!(
20
+ :credentials,
21
+ store: credentials_path
22
+ ) do
23
+ ::Rails.application.credentials.config[name.to_sym]
24
+ end.then do |creds|
25
+ Utils.deep_merge!(config, creds) if creds
26
+ end
27
+
28
+ if use_local?
29
+ trace!(:credentials, store: LOCAL_CONTENT_PATH) do
30
+ local_credentials(name)
31
+ end.then { |creds| Utils.deep_merge!(config, creds) if creds }
32
+ end
33
+
34
+ config
35
+ end
36
+
37
+ def schemaful?() ; true; end
38
+
39
+ private
40
+
41
+ def local_credentials(name)
42
+ local_creds_path = ::Rails.root.join(LOCAL_CONTENT_PATH).to_s
43
+
44
+ return unless File.file?(local_creds_path)
45
+
46
+ creds = ::Rails.application.encrypted(
47
+ local_creds_path,
48
+ key_path: ::Rails.root.join("config/credentials/local.key")
49
+ )
50
+
51
+ creds.config[name.to_sym]
52
+ end
53
+
54
+ def credentials_path
55
+ if ::Rails.application.config.respond_to?(:credentials)
56
+ ::Rails.root.join(::Rails.application.config.credentials.content_path).relative_path_from(::Rails.root).to_s
57
+ else
58
+ "config/credentials.yml.enc"
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anyway
4
+ module Rails
5
+ module Loaders
6
+ class Secrets < Anyway::Loaders::Base
7
+ def call(name:, **_options)
8
+ return {} unless ::Rails.application.respond_to?(:secrets)
9
+
10
+ # Create a new hash cause secrets are mutable!
11
+ config = {}
12
+
13
+ trace!(:secrets) do
14
+ secrets.public_send(name)
15
+ end.then do |secrets|
16
+ Utils.deep_merge!(config, secrets) if secrets
17
+ end
18
+
19
+ config
20
+ end
21
+
22
+ def schemaful?() ; true; end
23
+
24
+ private
25
+
26
+ def secrets
27
+ @secrets ||= ::Rails.application.secrets.tap do |_|
28
+ # Reset secrets state if the app hasn't been initialized
29
+ # See https://github.com/palkan/anyway_config/issues/14
30
+ next if ::Rails.application.initialized?
31
+
32
+ # Address unable to load application: NameError: instance variable @secrets
33
+ # not defined
34
+ next unless ::Rails.application.instance_variable_defined?(:@secrets)
35
+
36
+ ::Rails.application.remove_instance_variable(:@secrets)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ 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
 
@@ -467,6 +478,43 @@ module Anyway # :nodoc:
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
 
@@ -467,6 +478,43 @@ module Anyway # :nodoc:
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
 
@@ -467,6 +478,43 @@ module Anyway # :nodoc:
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
 
@@ -467,6 +478,43 @@ module Anyway # :nodoc:
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.0"
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
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: anyway_config
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.8.1
4
+ version: 2.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vladimir Dementyev
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-23 00:00:00.000000000 Z
11
+ date: 2026-10-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ruby-next-core
@@ -130,7 +130,10 @@ files:
130
130
  - lib/.rbnext/3.0/anyway/config.rb
131
131
  - lib/.rbnext/3.0/anyway/loaders.rb
132
132
  - lib/.rbnext/3.0/anyway/loaders/base.rb
133
+ - lib/.rbnext/3.0/anyway/loaders/ejson.rb
133
134
  - lib/.rbnext/3.0/anyway/loaders/yaml.rb
135
+ - lib/.rbnext/3.0/anyway/rails/loaders/credentials.rb
136
+ - lib/.rbnext/3.0/anyway/rails/loaders/secrets.rb
134
137
  - lib/.rbnext/3.0/anyway/tracing.rb
135
138
  - lib/.rbnext/3.1/anyway/config.rb
136
139
  - lib/.rbnext/3.1/anyway/dynamic_config.rb