myway_config 0.1.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.
@@ -0,0 +1,304 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'anyway_config'
4
+ require 'yaml'
5
+
6
+ module MywayConfig
7
+ # Base configuration class that extends Anyway::Config with additional features
8
+ #
9
+ # Provides:
10
+ # - ConfigSection coercion for nested configuration
11
+ # - XDG config file loading
12
+ # - Bundled defaults loading with environment overrides
13
+ # - Environment detection helpers
14
+ # - Deep merge utilities
15
+ #
16
+ # @example Define a configuration class (recommended)
17
+ # class MyApp::Config < MywayConfig::Base
18
+ # config_name :myapp
19
+ # env_prefix :myapp
20
+ # defaults_path File.expand_path('config/defaults.yml', __dir__)
21
+ # auto_configure!
22
+ # end
23
+ #
24
+ # @example Manual configuration (when custom coercions are needed)
25
+ # class MyApp::Config < MywayConfig::Base
26
+ # config_name :myapp
27
+ # env_prefix :myapp
28
+ # defaults_path File.expand_path('config/defaults.yml', __dir__)
29
+ #
30
+ # attr_config :database, :api, :log_level
31
+ #
32
+ # coerce_types(
33
+ # database: config_section_coercion(:database),
34
+ # api: config_section_coercion(:api),
35
+ # log_level: ->(v) { v.to_s.upcase.to_sym }
36
+ # )
37
+ # end
38
+ #
39
+ class Base < Anyway::Config
40
+ class << self
41
+ # Register a defaults file path for this config class
42
+ #
43
+ # @param path [String] absolute path to the defaults.yml file
44
+ # @raise [ConfigurationError] if the file does not exist
45
+ def defaults_path(path = nil)
46
+ if path
47
+ raise ConfigurationError, "Defaults file not found: #{path}" unless File.exist?(path)
48
+ @defaults_path = path
49
+ # Register with the loader
50
+ MywayConfig::Loaders::DefaultsLoader.register(config_name, path)
51
+ end
52
+ @defaults_path
53
+ end
54
+
55
+ # Load and cache the schema from defaults file
56
+ #
57
+ # @return [Hash] the defaults section from the YAML file
58
+ def schema
59
+ @schema ||= begin
60
+ return {} unless @defaults_path && File.exist?(@defaults_path)
61
+
62
+ content = File.read(@defaults_path)
63
+ raw = YAML.safe_load(
64
+ content,
65
+ permitted_classes: [Symbol],
66
+ symbolize_names: true,
67
+ aliases: true
68
+ ) || {}
69
+ raw[:defaults] || {}
70
+ end
71
+ end
72
+
73
+ # Create a coercion that merges incoming value with schema defaults for a section
74
+ #
75
+ # This ensures environment variables don't lose other defaults.
76
+ #
77
+ # @param section_key [Symbol] the section key in the schema
78
+ # @return [Proc] coercion proc for use with coerce_types
79
+ def config_section_coercion(section_key)
80
+ defaults = schema[section_key] || {}
81
+ ->(v) {
82
+ return v if v.is_a?(MywayConfig::ConfigSection)
83
+
84
+ incoming = v || {}
85
+ # Deep merge: defaults first, then overlay incoming values
86
+ merged = deep_merge_hashes(defaults.dup, incoming)
87
+ MywayConfig::ConfigSection.new(merged)
88
+ }
89
+ end
90
+
91
+ # Simple ConfigSection coercion without schema defaults
92
+ #
93
+ # @return [Proc] coercion proc for use with coerce_types
94
+ def config_section
95
+ ->(v) {
96
+ return v if v.is_a?(MywayConfig::ConfigSection)
97
+ MywayConfig::ConfigSection.new(v || {})
98
+ }
99
+ end
100
+
101
+ # Symbol coercion helper
102
+ #
103
+ # @return [Proc] coercion proc that converts to symbol
104
+ def to_symbol
105
+ ->(v) { v.nil? ? nil : v.to_s.to_sym }
106
+ end
107
+
108
+ # Deep merge helper for coercion
109
+ #
110
+ # @param base [Hash] base hash
111
+ # @param overlay [Hash] overlay hash (takes precedence)
112
+ # @return [Hash] merged hash
113
+ def deep_merge_hashes(base, overlay)
114
+ base.merge(overlay) do |_key, old_val, new_val|
115
+ if old_val.is_a?(Hash) && new_val.is_a?(Hash)
116
+ deep_merge_hashes(old_val, new_val)
117
+ else
118
+ new_val.nil? ? old_val : new_val
119
+ end
120
+ end
121
+ end
122
+
123
+ # Get the current environment
124
+ #
125
+ # Override this method to customize environment detection.
126
+ # Default priority: RAILS_ENV > RACK_ENV > 'development'
127
+ #
128
+ # @return [String] current environment name
129
+ def env
130
+ Anyway::Settings.current_environment ||
131
+ ENV['RAILS_ENV'] ||
132
+ ENV['RACK_ENV'] ||
133
+ 'development'
134
+ end
135
+
136
+ # Returns list of valid environment names from bundled defaults
137
+ #
138
+ # @return [Array<Symbol>] valid environment names
139
+ def valid_environments
140
+ MywayConfig::Loaders::DefaultsLoader.valid_environments(config_name)
141
+ end
142
+
143
+ # Check if current environment is valid
144
+ #
145
+ # @return [Boolean] true if environment has a config section
146
+ def valid_environment?
147
+ MywayConfig::Loaders::DefaultsLoader.valid_environment?(config_name, env)
148
+ end
149
+
150
+ # Define predicate methods for each environment in the config
151
+ #
152
+ # Generates instance methods like `development?`, `production?`, `staging?`
153
+ # based on the environment names found in the YAML config file.
154
+ #
155
+ # @return [Array<Symbol>] list of defined method names
156
+ def define_environment_predicates
157
+ valid_environments.map do |env_name|
158
+ method_name = "#{env_name}?"
159
+ define_method(method_name) do
160
+ self.class.env == env_name.to_s
161
+ end
162
+ method_name.to_sym
163
+ end
164
+ end
165
+
166
+ # Auto-configure attributes and coercions from the YAML schema
167
+ #
168
+ # This method reads the defaults section from the YAML file and
169
+ # automatically generates attr_config declarations and coercions.
170
+ # Hash values become ConfigSection objects with method-style access.
171
+ #
172
+ # @example Minimal config class
173
+ # class Xyzzy::Config < MywayConfig::Base
174
+ # config_name :xyzzy
175
+ # env_prefix :xyzzy
176
+ # defaults_path File.expand_path('config/defaults.yml', __dir__)
177
+ # auto_configure!
178
+ # end
179
+ #
180
+ def auto_configure!
181
+ raise ConfigurationError, 'defaults_path must be set before auto_configure!' unless @defaults_path
182
+
183
+ coercions = {}
184
+
185
+ schema.each do |key, value|
186
+ # Pass boolean defaults to attr_config so anyway_config creates predicate methods
187
+ if boolean?(value)
188
+ attr_config key => value
189
+ else
190
+ attr_config key
191
+ end
192
+
193
+ coercions[key] = if value.is_a?(Hash)
194
+ config_section_coercion(key)
195
+ elsif value.is_a?(Symbol)
196
+ to_symbol
197
+ end
198
+ end
199
+
200
+ coerce_types(coercions.compact)
201
+ define_environment_predicates
202
+ validate_environment!
203
+ end
204
+
205
+ # Check if a value is a boolean
206
+ #
207
+ # @param value [Object] the value to check
208
+ # @return [Boolean] true if value is true or false
209
+ def boolean?(value)
210
+ value.is_a?(TrueClass) || value.is_a?(FalseClass)
211
+ end
212
+
213
+ # Validate that the current environment is defined in the config
214
+ #
215
+ # @raise [ConfigurationError] if environment is not valid
216
+ # @return [void]
217
+ def validate_environment!
218
+ return unless @defaults_path
219
+ return if valid_environment?
220
+
221
+ raise ConfigurationError,
222
+ "Invalid environment '#{env}'. Valid environments: #{valid_environments.join(', ')}"
223
+ end
224
+ end
225
+
226
+ # ==========================================================================
227
+ # Instance Methods
228
+ # ==========================================================================
229
+
230
+ # Initialize configuration from defaults, a file path, or a Hash
231
+ #
232
+ # @param source [nil, String, Pathname, Hash] configuration source
233
+ # - nil: use defaults and environment overrides
234
+ # - String/Pathname: path to a YAML config file
235
+ # - Hash: direct configuration overrides
236
+ #
237
+ # @example Standard usage (defaults + environment)
238
+ # config = MyConfig.new
239
+ #
240
+ # @example Load from a custom file path
241
+ # config = MyConfig.new('/path/to/custom.yml')
242
+ # config = MyConfig.new(Pathname.new('/path/to/custom.yml'))
243
+ #
244
+ # @example With Hash overrides
245
+ # config = MyConfig.new(database: { host: 'custom.local' })
246
+ #
247
+ def initialize(source = nil)
248
+ overrides = case source
249
+ when String, Pathname
250
+ load_from_file(source.to_s)
251
+ when Hash
252
+ source
253
+ when nil
254
+ nil
255
+ else
256
+ raise ConfigurationError, "Invalid source: expected String, Pathname, Hash, or nil"
257
+ end
258
+
259
+ self.class.validate_environment!
260
+ super(overrides)
261
+ end
262
+
263
+ private
264
+
265
+ def load_from_file(path)
266
+ raise ConfigurationError, "Config file not found: #{path}" unless File.exist?(path)
267
+
268
+ content = File.read(path)
269
+ raw = YAML.safe_load(
270
+ content,
271
+ permitted_classes: [Symbol],
272
+ symbolize_names: true,
273
+ aliases: true
274
+ ) || {}
275
+
276
+ environment = self.class.env
277
+
278
+ if raw.key?(:defaults)
279
+ base = raw[:defaults] || {}
280
+ env_key = environment.to_sym
281
+ env_overrides = raw[env_key] || {}
282
+ self.class.deep_merge_hashes(base, env_overrides)
283
+ else
284
+ raw
285
+ end
286
+ end
287
+
288
+ public
289
+
290
+ # Get the current environment name
291
+ #
292
+ # @return [String]
293
+ def environment
294
+ self.class.env
295
+ end
296
+
297
+ # Check if environment is valid
298
+ #
299
+ # @return [Boolean]
300
+ def valid_environment?
301
+ self.class.valid_environment?
302
+ end
303
+ end
304
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MywayConfig
4
+ # ConfigSection provides method access to nested configuration hashes
5
+ #
6
+ # This allows configuration values to be accessed using method syntax
7
+ # instead of hash bracket notation, making config access more readable.
8
+ # Includes Enumerable for full Hash-like iteration support.
9
+ #
10
+ # @example Basic usage
11
+ # section = ConfigSection.new(host: 'localhost', port: 5432)
12
+ # section.host # => 'localhost'
13
+ # section[:host] # => 'localhost'
14
+ # section['host'] # => 'localhost'
15
+ #
16
+ # @example Nested sections
17
+ # section = ConfigSection.new(database: { host: 'localhost', port: 5432 })
18
+ # section.database.host # => 'localhost'
19
+ #
20
+ # @example Hash-like behavior
21
+ # section.keys # => [:host, :port]
22
+ # section.values # => ['localhost', 5432]
23
+ # section.fetch(:host) # => 'localhost'
24
+ # section.map { |k, v| "#{k}=#{v}" } # => ['host=localhost', 'port=5432']
25
+ #
26
+ class ConfigSection
27
+ include Enumerable
28
+ def initialize(hash = {})
29
+ @data = {}
30
+ (hash || {}).each do |key, value|
31
+ @data[key.to_sym] = value.is_a?(Hash) ? ConfigSection.new(value) : value
32
+ end
33
+ end
34
+
35
+ def method_missing(method, *args, &block)
36
+ key = method.to_s
37
+ if key.end_with?('=')
38
+ @data[key.chomp('=').to_sym] = args.first
39
+ elsif @data.key?(method)
40
+ @data[method]
41
+ else
42
+ nil
43
+ end
44
+ end
45
+
46
+ def respond_to_missing?(method, include_private = false)
47
+ key = method.to_s.chomp('=').to_sym
48
+ @data.key?(key) || super
49
+ end
50
+
51
+ # Convert to a plain Ruby hash
52
+ #
53
+ # @return [Hash] the configuration as a hash
54
+ def to_h
55
+ @data.transform_values do |v|
56
+ v.is_a?(ConfigSection) ? v.to_h : v
57
+ end
58
+ end
59
+
60
+ # Access a value by key
61
+ #
62
+ # @param key [Symbol, String] the key to access
63
+ # @return [Object] the value
64
+ def [](key)
65
+ @data[key.to_sym]
66
+ end
67
+
68
+ # Set a value by key
69
+ #
70
+ # @param key [Symbol, String] the key to set
71
+ # @param value [Object] the value to set
72
+ def []=(key, value)
73
+ @data[key.to_sym] = value
74
+ end
75
+
76
+ # Merge with another ConfigSection or hash
77
+ #
78
+ # @param other [ConfigSection, Hash] the other config to merge
79
+ # @return [ConfigSection] a new merged ConfigSection
80
+ def merge(other)
81
+ other_hash = other.is_a?(ConfigSection) ? other.to_h : other
82
+ ConfigSection.new(deep_merge(to_h, other_hash || {}))
83
+ end
84
+
85
+ # Get all keys
86
+ #
87
+ # @return [Array<Symbol>] the keys
88
+ def keys
89
+ @data.keys
90
+ end
91
+
92
+ # Iterate over key-value pairs
93
+ #
94
+ # @yield [key, value] each key-value pair
95
+ def each(&block)
96
+ @data.each(&block)
97
+ end
98
+
99
+ # Check if a key exists
100
+ #
101
+ # @param key [Symbol, String] the key to check
102
+ # @return [Boolean] true if the key exists
103
+ def key?(key)
104
+ @data.key?(key.to_sym)
105
+ end
106
+
107
+ # Check if the section is empty
108
+ #
109
+ # @return [Boolean] true if no keys are present
110
+ def empty?
111
+ @data.empty?
112
+ end
113
+
114
+ # Get all values
115
+ #
116
+ # @return [Array] the values
117
+ def values
118
+ @data.values
119
+ end
120
+
121
+ # Get the number of keys
122
+ #
123
+ # @return [Integer] the number of keys
124
+ def size
125
+ @data.size
126
+ end
127
+ alias length size
128
+
129
+ # Fetch a value with optional default
130
+ #
131
+ # @param key [Symbol, String] the key to fetch
132
+ # @param default [Object] optional default value
133
+ # @yield optional block for default value
134
+ # @return [Object] the value or default
135
+ # @raise [KeyError] if key not found and no default given
136
+ def fetch(key, *args, &block)
137
+ @data.fetch(key.to_sym, *args, &block)
138
+ end
139
+
140
+ # Dig into nested values
141
+ #
142
+ # @param keys [Array<Symbol, String>] the keys to dig through
143
+ # @return [Object, nil] the value or nil if not found
144
+ def dig(*keys)
145
+ keys.reduce(self) do |obj, key|
146
+ return nil unless obj.respond_to?(:[])
147
+ obj[key]
148
+ end
149
+ end
150
+
151
+ # Alias for key? to match Hash interface
152
+ alias has_key? key?
153
+ alias include? key?
154
+ alias member? key?
155
+
156
+ private
157
+
158
+ def deep_merge(base, overlay)
159
+ base.merge(overlay) do |_key, old_val, new_val|
160
+ if old_val.is_a?(Hash) && new_val.is_a?(Hash)
161
+ deep_merge(old_val, new_val)
162
+ else
163
+ new_val
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'anyway_config'
4
+ require 'yaml'
5
+
6
+ module MywayConfig
7
+ module Loaders
8
+ # Bundled Defaults Loader for Anyway Config
9
+ #
10
+ # Loads default configuration values from a YAML file bundled with a gem.
11
+ # This ensures defaults are always available regardless of where the gem is installed.
12
+ #
13
+ # The defaults.yml file structure:
14
+ # defaults: # Base values for all environments
15
+ # database:
16
+ # host: localhost
17
+ # port: 5432
18
+ # development: # Overrides for development
19
+ # database:
20
+ # name: myapp_development
21
+ # test: # Overrides for test
22
+ # database:
23
+ # name: myapp_test
24
+ # production: # Overrides for production
25
+ # database:
26
+ # sslmode: require
27
+ #
28
+ # This loader deep-merges `defaults` with the current environment's overrides.
29
+ #
30
+ # Loading priority (lowest to highest):
31
+ # 1. Bundled defaults (this loader)
32
+ # 2. XDG user config (~/.config/app/app.yml)
33
+ # 3. Project config (./config/app.yml)
34
+ # 4. Local overrides (./config/app.local.yml)
35
+ # 5. Environment variables (APP_*)
36
+ # 6. Programmatic (configure block)
37
+ #
38
+ class DefaultsLoader < Anyway::Loaders::Base
39
+ # Registry of defaults paths keyed by config name
40
+ @defaults_paths = {}
41
+
42
+ class << self
43
+ attr_reader :defaults_paths
44
+
45
+ # Register a defaults file path for a config name
46
+ #
47
+ # @param name [Symbol, String] the config name
48
+ # @param path [String] absolute path to the defaults.yml file
49
+ def register(name, path)
50
+ @defaults_paths[name.to_sym] = path
51
+ end
52
+
53
+ # Get the registered defaults path for a config name
54
+ #
55
+ # @param name [Symbol, String] the config name
56
+ # @return [String, nil] the path or nil if not registered
57
+ def defaults_path(name)
58
+ @defaults_paths[name.to_sym]
59
+ end
60
+
61
+ # Check if defaults file exists for a config name
62
+ #
63
+ # @param name [Symbol, String] the config name
64
+ # @return [Boolean]
65
+ def defaults_exist?(name)
66
+ path = defaults_path(name)
67
+ path && File.exist?(path)
68
+ end
69
+
70
+ # Load and parse the raw YAML content
71
+ #
72
+ # @param name [Symbol, String] the config name
73
+ # @return [Hash] parsed YAML with symbolized keys
74
+ def load_raw_yaml(name)
75
+ path = defaults_path(name)
76
+ return {} unless path && File.exist?(path)
77
+
78
+ content = File.read(path)
79
+ YAML.safe_load(
80
+ content,
81
+ permitted_classes: [Symbol],
82
+ symbolize_names: true,
83
+ aliases: true
84
+ ) || {}
85
+ rescue Psych::SyntaxError => e
86
+ warn "MywayConfig: Failed to parse bundled defaults #{path}: #{e.message}"
87
+ {}
88
+ end
89
+
90
+ # Extract the schema (attribute names) from the defaults section
91
+ #
92
+ # @param name [Symbol, String] the config name
93
+ # @return [Hash] the defaults section containing all attribute definitions
94
+ def schema(name)
95
+ raw = load_raw_yaml(name)
96
+ raw[:defaults] || {}
97
+ end
98
+
99
+ # Returns valid environment names from the config file
100
+ #
101
+ # Valid environments are top-level keys excluding 'defaults'.
102
+ #
103
+ # @param name [Symbol, String] the config name
104
+ # @return [Array<Symbol>] list of valid environment names
105
+ def valid_environments(name)
106
+ raw = load_raw_yaml(name)
107
+ raw.keys.reject { |k| k == :defaults }.sort
108
+ end
109
+
110
+ # Check if a given environment name is valid
111
+ #
112
+ # @param name [Symbol, String] the config name
113
+ # @param env [String, Symbol] environment name to check
114
+ # @return [Boolean] true if environment is valid
115
+ def valid_environment?(name, env)
116
+ return false if env.nil? || env.to_s.empty?
117
+ return false if env.to_s == 'defaults'
118
+
119
+ valid_environments(name).include?(env.to_sym)
120
+ end
121
+ end
122
+
123
+ def call(name:, **_options)
124
+ return {} unless self.class.defaults_exist?(name)
125
+
126
+ path = self.class.defaults_path(name)
127
+ trace!(:bundled_defaults, path: path) do
128
+ load_and_merge_for_environment(name)
129
+ end
130
+ end
131
+
132
+ private
133
+
134
+ # Load defaults and deep merge with environment-specific overrides
135
+ #
136
+ # @param name [Symbol, String] the config name
137
+ # @return [Hash] merged configuration for current environment
138
+ def load_and_merge_for_environment(name)
139
+ raw = self.class.load_raw_yaml(name)
140
+ return {} if raw.empty?
141
+
142
+ # Start with the defaults section
143
+ defaults = raw[:defaults] || {}
144
+
145
+ # Deep merge with environment-specific overrides
146
+ env = current_environment
147
+ env_overrides = raw[env.to_sym] || {}
148
+
149
+ deep_merge(defaults, env_overrides)
150
+ end
151
+
152
+ # Deep merge two hashes, with overlay taking precedence
153
+ #
154
+ # @param base [Hash] base configuration
155
+ # @param overlay [Hash] overlay configuration (takes precedence)
156
+ # @return [Hash] merged result
157
+ def deep_merge(base, overlay)
158
+ base.merge(overlay) do |_key, old_val, new_val|
159
+ if old_val.is_a?(Hash) && new_val.is_a?(Hash)
160
+ deep_merge(old_val, new_val)
161
+ else
162
+ new_val
163
+ end
164
+ end
165
+ end
166
+
167
+ # Determine the current environment
168
+ #
169
+ # @return [String] current environment name
170
+ def current_environment
171
+ Anyway::Settings.current_environment ||
172
+ ENV['RAILS_ENV'] ||
173
+ ENV['RACK_ENV'] ||
174
+ 'development'
175
+ end
176
+ end
177
+ end
178
+ end