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.
- checksums.yaml +7 -0
- data/.envrc +1 -0
- data/CHANGELOG.md +5 -0
- data/COMMITS.md +196 -0
- data/LICENSE.txt +21 -0
- data/README.md +235 -0
- data/Rakefile +8 -0
- data/docs/api/base.md +292 -0
- data/docs/api/config-section.md +368 -0
- data/docs/api/index.md +85 -0
- data/docs/api/loaders.md +296 -0
- data/docs/development/contributing.md +215 -0
- data/docs/development/index.md +100 -0
- data/docs/development/testing.md +276 -0
- data/docs/examples/basic-usage.md +173 -0
- data/docs/examples/index.md +101 -0
- data/docs/examples/rails-integration.md +302 -0
- data/docs/examples/standalone-app.md +278 -0
- data/docs/getting-started/index.md +23 -0
- data/docs/getting-started/installation.md +50 -0
- data/docs/getting-started/quick-start.md +109 -0
- data/docs/guides/accessing-values.md +153 -0
- data/docs/guides/custom-file-loading.md +161 -0
- data/docs/guides/defining-configuration.md +133 -0
- data/docs/guides/environment-overrides.md +137 -0
- data/docs/guides/hash-like-behavior.md +220 -0
- data/docs/guides/index.md +15 -0
- data/docs/guides/yaml-structure.md +168 -0
- data/docs/index.md +86 -0
- data/lib/myway_config/base.rb +304 -0
- data/lib/myway_config/config_section.rb +168 -0
- data/lib/myway_config/loaders/defaults_loader.rb +178 -0
- data/lib/myway_config/loaders/xdg_config_loader.rb +120 -0
- data/lib/myway_config/version.rb +5 -0
- data/lib/myway_config.rb +66 -0
- data/mkdocs.yml +148 -0
- data/sig/myway_config.rbs +4 -0
- metadata +96 -0
|
@@ -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
|