anyway_app_config 0.3.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 617b75e0653ccf7f6cd5990ed5484810d48d08e799a1d8e6b526208499fbbe14
4
+ data.tar.gz: c9a057c7b0fa2f0e1ae3fa34cdfbdd0fd573113ee8002731756952815a6c2f7a
5
+ SHA512:
6
+ metadata.gz: b5ed356d38908f1a8db01006504b87eb74d731b0c7dc71dc7a6df9d006ad511ca795f001daac49184272959e5dd021831a544dd86b8426eafadd6fbf265d748c
7
+ data.tar.gz: '058d004053ece9aeb7600162b68d5a595641b6d052c3bb3726f65451e182bd1cdedd6e36b2a88e4145d26a0f54dd5929fabadd189b1e253b5cc1f673a7b9abfd'
@@ -0,0 +1,10 @@
1
+ # Code of Conduct
2
+
3
+ "anyway_app_config" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
4
+
5
+ * Participants will be tolerant of opposing views.
6
+ * Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
7
+ * When interpreting the words and actions of others, participants should always assume good intentions.
8
+ * Behaviour which can be reasonably considered harassment will not be tolerated.
9
+
10
+ If you have any concerns about behaviour within this project, please contact us at ["senid231@gmail.com"](mailto:"senid231@gmail.com").
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Denis Talakevich
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,505 @@
1
+ # AnywayAppConfig
2
+
3
+ Schema-driven application config built on top of [`anyway_config`][anyway_config].
4
+
5
+ `anyway_config` does the heavy lifting of loading values from YAML and ENV.
6
+ `anyway_app_config` adds a small DSL on top for describing app config with
7
+ typed attributes, defaults, required fields, and **nested objects** (single
8
+ or array). Configs can be used as plain instances or as a singleton.
9
+
10
+ ## Installation
11
+
12
+ Add to your Gemfile:
13
+
14
+ ```ruby
15
+ gem "anyway_app_config"
16
+ ```
17
+
18
+ Then run `bundle install`.
19
+
20
+ ## Defining a config
21
+
22
+ Inherit from `AnywayAppConfig::Config` and describe attributes with the
23
+ `attribute` DSL:
24
+
25
+ ```ruby
26
+ require "anyway_app_config"
27
+
28
+ class AppConfig < AnywayAppConfig::Config
29
+ config_name "app_config"
30
+ env_prefix "APP"
31
+
32
+ attribute :deploy_env, type: :string, required: true
33
+ attribute :version, type: :string, default: "unknown"
34
+ attribute :commit_sha, type: :string, default: "000000"
35
+
36
+ attribute :sentry, required: true do
37
+ attribute :dsn, type: :string, default: ""
38
+ attribute :environment, type: :string, required: true
39
+ attribute :server_name, type: :string, required: true
40
+ attribute :tags, type: :hash, default: {}
41
+ end
42
+
43
+ attribute :prometheus, required: true do
44
+ attribute :enabled, type: :boolean, default: false
45
+ attribute :host, type: :string, default: "localhost"
46
+ attribute :port, type: :integer, default: 9394
47
+ attribute :default_labels, type: :hash, default: {}
48
+ end
49
+ end
50
+ ```
51
+
52
+ `attribute` accepts:
53
+
54
+ | option | meaning |
55
+ | ---------- | ------------------------------------------------------------ |
56
+ | `type:` | type id from `anyway_config`'s registry (`:string`, `:integer`, `:float`, `:boolean`, `:date`, `:datetime`, `:uri`, `:hash`, …), or any object responding to `#call(value)` |
57
+ | `array:` | when `true`, value is an array of `type` (or nested objects) |
58
+ | `default:` | default value (defaults to `nil`, or `[]` when `array: true`) |
59
+ | `required:`| validate that the attribute is present and not empty |
60
+ | block | defines a nested config object (see below) |
61
+
62
+ ### Nested attributes
63
+
64
+ Pass a block to define a nested config. The DSL builds a child config class
65
+ that inherits from `AnywayAppConfig::Config`, exposes the same DSL, and is
66
+ exposed as a constant (e.g. `AppConfig::SentryCfg`).
67
+
68
+ Combine with `array: true` to get a list of nested objects:
69
+
70
+ ```ruby
71
+ class AppConfig < AnywayAppConfig::Config
72
+ config_name "app_config"
73
+
74
+ attribute :servers, array: true do
75
+ attribute :host, type: :string, required: true
76
+ attribute :port, type: :integer, default: 80
77
+ end
78
+ end
79
+ ```
80
+
81
+ ### The `:hash` type
82
+
83
+ `AnywayAppConfig::Config` registers a `:hash` type on a per-class type
84
+ registry. It accepts any `Hash` value as-is and raises `ArgumentError` for
85
+ non-hash values. Anyway's global `TypeRegistry.default` is **not** mutated.
86
+
87
+ ## Loading config
88
+
89
+ ```ruby
90
+ config = AppConfig.load! # all sources merged, contained values frozen
91
+ config.sentry.environment
92
+ config.servers.first.host
93
+ ```
94
+
95
+ `load!` returns a new instance every call (no caching). Sources are loaded
96
+ through `anyway_config` (YAML + ENV by default).
97
+
98
+ ### Freezing
99
+
100
+ On `load!` (and `deep_freeze_values!`) the config freezes all of its contained
101
+ values — Arrays, Hashes, and scalars — so the loaded data is effectively
102
+ immutable. The `Config` instance itself and any nested `Config` objects are
103
+ **not** frozen, so RSpec stubs keep working:
104
+
105
+ ```ruby
106
+ allow(AppConfig.sentry).to receive(:dsn).and_return("stubbed")
107
+ ```
108
+
109
+ If a specific value class can't be frozen (e.g. it holds mutable state like a
110
+ cache client or logger), add it to `skip_freeze_classes` to exclude it from
111
+ the freeze walk (matched via `is_a?`, inherited by subclasses):
112
+
113
+ ```ruby
114
+ class AppConfig < AnywayAppConfig::Config
115
+ self.skip_freeze_classes = [Logger, SomeCacheClient]
116
+ # ...
117
+ end
118
+ ```
119
+
120
+ ### Explicit config path
121
+
122
+ By default, `anyway_config` looks for `config/<config_name>.yml` (or whatever
123
+ `Anyway::Settings.default_config_path` resolves to). To point at a specific
124
+ YAML file, pass `config_path:` to `new`/`load!`:
125
+
126
+ ```ruby
127
+ AppConfig.load!(config_path: "/etc/myapp/app_config.yml")
128
+ ```
129
+
130
+ `config_path:` also forwards through `Singleton#load!`.
131
+
132
+ For a per-class default, set `explicit_config_path` on the class (inherited
133
+ by subclasses, overridden by a per-call `config_path:`). It accepts a `String`
134
+ or `Pathname` directly:
135
+
136
+ ```ruby
137
+ class AppConfig < AnywayAppConfig::Config
138
+ self.explicit_config_path = Rails.root.join('config', 'app_config.yml')
139
+ # ...
140
+ end
141
+ ```
142
+
143
+ Or a `Proc` for cases where the path depends on runtime state (called every
144
+ time a new instance is built):
145
+
146
+ ```ruby
147
+ class AppConfig < AnywayAppConfig::Config
148
+ self.explicit_config_path = -> { "/etc/myapp/#{ENV.fetch('APP_ENV')}.yml" }
149
+ # ...
150
+ end
151
+ ```
152
+
153
+ Anyway's other built-in mechanisms (`<CONFIG_NAME>_CONF` env var,
154
+ `Anyway::Settings.default_config_path` lambda) still work — `config_path:` and
155
+ `explicit_config_path` just give you a class-scoped, code-driven option.
156
+
157
+ ### Choosing a YAML loader
158
+
159
+ By default the `:yml` loader decides whether your YAML is environment-keyed
160
+ (`development:` / `production:` sections) or flat by inspecting the file
161
+ content and global state. To make that explicit, the gem registers two extra
162
+ loaders you can select via `configuration_sources`:
163
+
164
+ - `:flat_yml` — always reads top-level keys, ignores environment sections.
165
+ - `:env_yml` — always reads the section matching
166
+ `Anyway::Settings.current_environment`; raises if it is not set.
167
+
168
+ ```ruby
169
+ class Credentials < AnywayAppConfig::Config
170
+ config_name 'credentials'
171
+ self.configuration_sources = [:flat_yml, :env] # flat YAML + ENV overrides
172
+ end
173
+
174
+ class AppConfig < AnywayAppConfig::Config
175
+ config_name 'app_config'
176
+ self.configuration_sources = [:env_yml, :env] # env-keyed YAML + ENV overrides
177
+ end
178
+ ```
179
+
180
+ Pick one YAML loader per class — `:yml`, `:flat_yml`, and `:env_yml` all read
181
+ the same file, so listing more than one just loads it repeatedly. The default
182
+ `configuration_sources` is untouched, so classes that don't opt in keep using
183
+ `:yml`.
184
+
185
+ ### Singleton mode
186
+
187
+ Include `AnywayAppConfig::Singleton` to get a class-level singleton with
188
+ class-level access to all instance methods:
189
+
190
+ ```ruby
191
+ class AppConfig < AnywayAppConfig::Config
192
+ include AnywayAppConfig::Singleton
193
+ # ...
194
+ end
195
+
196
+ AppConfig.load! # values frozen, instance cached on the class
197
+ AppConfig.deploy_env # delegates to instance
198
+ AppConfig.sentry.environment # delegates to instance
199
+ AppConfig.instance # the cached instance
200
+ AppConfig.loaded? # true / false
201
+
202
+ AppConfig.load! # raises AnywayAppConfig::AlreadyLoadedError
203
+ AppConfig.foo # raises AnywayAppConfig::NotLoadedError if not loaded
204
+ ```
205
+
206
+ The singleton is intentionally strict — there is no `reload!`. To re-read
207
+ config, restart the process.
208
+
209
+ > Note: class-level delegation goes through `method_missing`, so attribute
210
+ > names that clash with existing `Class` methods (`name`, `class`, `send`, …)
211
+ > are not delegated — pick non-clashing names.
212
+
213
+ ### YAML and ENV
214
+
215
+ Loading is provided by `anyway_config`. A typical `config/app_config.yml`:
216
+
217
+ ```yaml
218
+ development: &dev
219
+ deploy_env: "development"
220
+
221
+ sentry:
222
+ dsn: ""
223
+ environment: "development"
224
+ server_name: "denis-t.localhost"
225
+ tags:
226
+ custom: "tag"
227
+
228
+ prometheus:
229
+ enabled: false
230
+ host: "localhost"
231
+ port: 9394
232
+
233
+ test:
234
+ <<: *dev
235
+ deploy_env: "test"
236
+
237
+ production:
238
+ <<: *dev
239
+ ```
240
+
241
+ ENV vars use the prefix declared via `env_prefix`, e.g. `APP_DEPLOY_ENV`,
242
+ `APP_SENTRY__ENVIRONMENT`. See the [anyway_config docs][anyway_config] for
243
+ the full source list and naming rules.
244
+
245
+ ## Rails
246
+
247
+ There is no Railtie — Rails already exposes `Rails.configuration` as the
248
+ canonical place to hang application-wide settings, so wiring through it is
249
+ three lines. The pattern:
250
+
251
+ **1. Define the config class in `config/app_config.rb`:**
252
+
253
+ ```ruby
254
+ require "anyway_app_config"
255
+
256
+ class AppConfig < AnywayAppConfig::Config
257
+ config_name "app_config"
258
+ env_prefix "APP"
259
+
260
+ attribute :deploy_env, type: :string, required: true
261
+ attribute :version, type: :string, default: "unknown"
262
+
263
+ attribute :sentry, required: true do
264
+ attribute :dsn, type: :string, default: ""
265
+ attribute :environment, type: :string, required: true
266
+ end
267
+ end
268
+ ```
269
+
270
+ **2. (Optional) Add `config/app_config.yml`:**
271
+
272
+ ```yaml
273
+ development:
274
+ deploy_env: "development"
275
+ sentry:
276
+ environment: "development"
277
+
278
+ production:
279
+ deploy_env: "production"
280
+ sentry:
281
+ environment: "production"
282
+ ```
283
+
284
+ Any value can be overridden via ENV using the `env_prefix` (`APP` above).
285
+ Examples:
286
+
287
+ - `APP_DEPLOY_ENV=staging` → `AppConfig#deploy_env`
288
+ - `APP_SENTRY__DSN=https://...` → `AppConfig#sentry.dsn` (double underscore for nesting)
289
+ - `APP_VERSION=1.2.3` → `AppConfig#version`
290
+
291
+ ENV wins over YAML. See the [anyway_config docs][anyway_config] for the full
292
+ source list and naming rules.
293
+
294
+ **3. Load and assign in `config/application.rb`:**
295
+
296
+ ```ruby
297
+ require_relative "app_config"
298
+
299
+ module MyApp
300
+ class Application < Rails::Application
301
+ config.app_config = AppConfig.load!
302
+ # ...
303
+ end
304
+ end
305
+ ```
306
+
307
+ **4. Use it anywhere via `Rails.configuration`:**
308
+
309
+ ```ruby
310
+ Rails.configuration.app_config.deploy_env
311
+ Rails.configuration.app_config.sentry.dsn
312
+ ```
313
+
314
+ `AppConfig.load!` returns an instance with frozen values, so
315
+ `Rails.configuration.app_config` is safe to read from any thread. If you want class-level access
316
+ (`AppConfig.deploy_env`) instead, use [Singleton mode](#singleton-mode) and
317
+ just call `AppConfig.load!` in `config/application.rb` without assigning it
318
+ to `config.app_config`.
319
+
320
+ ### Replacing `Rails.application.credentials` (unencrypted)
321
+
322
+ Rails 7.2+ encrypts `config/credentials.yml.enc` by default. If your secrets
323
+ are already injected by your deploy pipeline (Helm, Kubernetes secrets, CI/CD
324
+ vault, ENV) you don't need encryption-at-rest in the repo — and the encrypted
325
+ credentials flow becomes pure overhead. `anyway_app_config` is a drop-in
326
+ replacement: typed, required-checked, value-frozen, and ENV-overridable.
327
+
328
+ **1. Define the credentials class in `config/credentials.rb`:**
329
+
330
+ ```ruby
331
+ require "anyway_app_config"
332
+
333
+ class Credentials < AnywayAppConfig::Config
334
+ config_name "credentials"
335
+ env_prefix "" # no prefix — read raw ENV like SECRET_KEY_BASE, DATABASE_PASSWORD
336
+ self.configuration_sources = [:yml, :env] # see note below
337
+
338
+ attribute :secret_key_base, type: :string, required: true
339
+ attribute :database_password, type: :string, required: true
340
+ attribute :aws_access_key_id, type: :string, default: ""
341
+ attribute :aws_secret_access_key, type: :string, default: ""
342
+ end
343
+ ```
344
+
345
+ > **Pin `configuration_sources` explicitly.** Under Rails, `anyway_config`
346
+ > registers a `:credentials` loader that reads from
347
+ > `Rails.application.credentials` — exactly the thing you're trying to
348
+ > replace. If you don't override `configuration_sources`, your shiny new
349
+ > `Credentials` class will silently merge values back in from the encrypted
350
+ > credentials file (or fail to boot if `RAILS_MASTER_KEY` is missing).
351
+ > Setting `self.configuration_sources = [:yml, :env]` keeps loading limited
352
+ > to YAML + ENV and removes the dependency on the Rails credentials loader.
353
+
354
+ An empty `env_prefix` matches ENV var names directly against attribute names
355
+ (uppercased), so `SECRET_KEY_BASE` populates `:secret_key_base`. Useful for
356
+ secrets that have well-known unprefixed names — `SECRET_KEY_BASE`,
357
+ `DATABASE_URL`, `RAILS_MASTER_KEY`. Other ENV vars are simply ignored unless
358
+ they match a declared attribute. **Caveat:** pick attribute names carefully
359
+ — if you declare `attribute :path` or `:home` with empty prefix, you'll pick
360
+ up `PATH`/`HOME` from the shell. When in doubt, keep a prefix.
361
+
362
+ **2. Add `config/credentials.yml`** (gitignore it, or commit only the dev/test
363
+ branches and inject production values via ENV):
364
+
365
+ ```yaml
366
+ development:
367
+ secret_key_base: "dev-only-not-a-real-secret"
368
+ database_password: "postgres"
369
+
370
+ test:
371
+ secret_key_base: "test-only-not-a-real-secret"
372
+ database_password: "postgres"
373
+
374
+ production:
375
+ # Leave empty here and inject via ENV (SECRET_KEY_BASE=..., etc) at deploy time.
376
+ secret_key_base: ""
377
+ database_password: ""
378
+ ```
379
+
380
+ **3. Wire it in `config/application.rb`:**
381
+
382
+ ```ruby
383
+ require_relative "credentials"
384
+
385
+ module MyApp
386
+ class Application < Rails::Application
387
+ config.load_defaults 8.1
388
+
389
+ Rails.application.credentials = Credentials.load!
390
+ # Rails 7.2+ generates a dynamic secret_key_base in dev/test when one
391
+ # is not set. Pin it from credentials so it stays stable across boots.
392
+ # See Rails::Application::Configuration#generate_local_secret?
393
+ config.secret_key_base = Rails.application.credentials.secret_key_base
394
+ end
395
+ end
396
+ ```
397
+
398
+ Assigning directly inside the class body works because `Rails.application` is
399
+ already available by the time `config/application.rb` is evaluated. If you
400
+ need the assignment deferred (e.g. credentials depend on something set up by
401
+ an initializer or another `before_configuration` hook), wrap it in
402
+ `config.before_configuration { ... }` instead.
403
+
404
+ **4. Use it like the standard credentials object:**
405
+
406
+ ```ruby
407
+ Rails.application.credentials.secret_key_base
408
+ Rails.application.credentials.database_password
409
+ ```
410
+
411
+ ENV overrides use raw names because of the empty `env_prefix`:
412
+ `SECRET_KEY_BASE=...`, `DATABASE_PASSWORD=...`.
413
+
414
+ > **Trade-off:** you lose encryption-at-rest. Only do this if `credentials.yml`
415
+ > is gitignored or contains placeholders only, with real values injected via
416
+ > ENV at deploy time. The win is that secrets become typed, required-checked,
417
+ > and centrally declared — instead of an opaque `EncryptedConfiguration` blob
418
+ > that silently returns `nil` for typos.
419
+
420
+ ### Inline form: `AnywayAppConfig.build`
421
+
422
+ For small Rails apps where dedicated `config/app_config.rb` and
423
+ `config/credentials.rb` files feel like overkill, declare configs inline in
424
+ `config/application.rb` with `AnywayAppConfig.build`:
425
+
426
+ ```ruby
427
+ module MyApp
428
+ class Application < Rails::Application
429
+ Rails.application.credentials = AnywayAppConfig.build(load: true) do
430
+ config_name "credentials"
431
+ env_prefix "" # no prefix — read raw ENV like SECRET_KEY_BASE
432
+
433
+ attribute :secret_key_base, type: :string, required: true
434
+ attribute :database_password, type: :string, required: true
435
+ attribute :aws_access_key_id, type: :string, default: ""
436
+ attribute :aws_secret_access_key, type: :string, default: ""
437
+ end
438
+ config.secret_key_base = Rails.application.credentials.secret_key_base
439
+
440
+ config.app_config = AnywayAppConfig.build(load: true) do
441
+ config_name "app_config"
442
+ env_prefix "APP"
443
+
444
+ attribute :deploy_env, type: :string, required: true
445
+ attribute :version, type: :string, default: "unknown"
446
+
447
+ attribute :sentry, required: true do
448
+ attribute :dsn, type: :string, default: ""
449
+ attribute :environment, type: :string, required: true
450
+ end
451
+ end
452
+ end
453
+ end
454
+ ```
455
+
456
+ `AnywayAppConfig.build(&block)` returns an anonymous `AnywayAppConfig::Config`
457
+ subclass; `build(load: true, &block)` calls `load!` and returns the loaded
458
+ instance (with frozen values). The block is `class_eval`'d on the new subclass, so `config_name`,
459
+ `env_prefix`, and `attribute` are available exactly as in a named class.
460
+
461
+ `config_name` is **mandatory** — anonymous classes have no name, so
462
+ `anyway_config` can't infer it. Otherwise YAML loading and ENV nesting work
463
+ identically (so `config/credentials.yml`, `config/app_config.yml`, and
464
+ `APP_SENTRY__DSN` all behave the same as the file-based form).
465
+
466
+ #### Disabling ENV loading entirely
467
+
468
+ If you want a config to read **only** from YAML and ignore the environment
469
+ (e.g. for credentials that must never leak in via stray ENV vars, or to make
470
+ behavior deterministic in tests), restrict `configuration_sources` on the
471
+ class to the YAML loader:
472
+
473
+ ```ruby
474
+ class Credentials < AnywayAppConfig::Config
475
+ config_name "credentials"
476
+ self.configuration_sources = [:yml] # YAML only — ignore ENV, ignore Rails secrets/credentials loaders
477
+
478
+ attribute :secret_key_base, type: :string, required: true
479
+ attribute :database_password, type: :string, required: true
480
+ end
481
+ ```
482
+
483
+ `configuration_sources` is an array of loader IDs registered on
484
+ `Anyway.loaders` (`:yml`, `:env`, `:credentials`, etc — Rails adds a few).
485
+ Only listed loaders run for this class. With just `[:yml]`, ENV variables
486
+ have no effect on the values, regardless of `env_prefix`.
487
+
488
+ ## Development
489
+
490
+ ```
491
+ bundle install
492
+ bundle exec rspec
493
+ bundle exec rubocop
494
+ ```
495
+
496
+ ## Contributing
497
+
498
+ Bug reports and pull requests are welcome on GitHub at
499
+ <https://github.com/senid231/anyway_app_config>.
500
+
501
+ ## License
502
+
503
+ MIT. See [LICENSE.txt](LICENSE.txt).
504
+
505
+ [anyway_config]: https://github.com/palkan/anyway_config
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require 'rubocop/rake_task'
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'anyway_config'
4
+ require 'active_support/core_ext/class/attribute'
5
+ require 'active_support/core_ext/hash/keys'
6
+ require 'active_support/core_ext/string/inflections'
7
+
8
+ module AnywayAppConfig
9
+ class NotLoadedError < StandardError; end
10
+
11
+ class Config < ::Anyway::Config
12
+ BaseNestedCfgClass = Class.new(self)
13
+
14
+ class_attribute :nested_config_class, instance_accessor: false, default: BaseNestedCfgClass
15
+ class_attribute :skip_freeze_classes, instance_accessor: false, default: [].freeze
16
+ class_attribute :explicit_config_path, instance_accessor: false
17
+
18
+ class << self
19
+ def attribute(name, type: nil, array: false, default: nil, required: false, &block)
20
+ if block
21
+ raise ArgumentError, "nested attribute #{name} does not support type" unless type.nil?
22
+ raise ArgumentError, "nested attribute #{name} does not support default" unless default.nil?
23
+
24
+ attr_nested(name, array: array, required: required, &block)
25
+ else
26
+ attr_value(name, type: type, array: array, default: default, required: required)
27
+ end
28
+ end
29
+
30
+ def attr_value(name, type:, array: false, default: nil, required: false)
31
+ default_val = default.nil? && array ? [] : default
32
+ attr_config(name => default_val)
33
+ coerce_types(name => { type: type, array: array })
34
+ self.required(name) if required
35
+ end
36
+
37
+ def attr_nested(name, array: false, required: false, &block)
38
+ nested_klass = Class.new(nested_config_class)
39
+ const_set("#{name.to_s.classify}Cfg", nested_klass)
40
+ # nested_klass.config_name represents path to config struct in main config file
41
+ if self < nested_config_class
42
+ # nested inside another nested config
43
+ nested_klass.config_name :"#{config_name}.#{name}"
44
+ else
45
+ # nested in main config
46
+ nested_klass.config_name name.to_sym
47
+ end
48
+ nested_klass.configuration_sources = []
49
+ nested_klass.class_eval(&block) if block
50
+
51
+ if array
52
+ attr_config(name => [])
53
+ caster = ->(v) { nested_klass.new(v) }
54
+ coerce_types(name => { type: caster, array: true })
55
+ else
56
+ attr_config(name => {})
57
+ coerce_types(name => { config: nested_klass })
58
+ end
59
+
60
+ self.required(name) if required
61
+ end
62
+
63
+ def load!(*, **)
64
+ new(*, **).tap(&:deep_freeze_values!)
65
+ end
66
+
67
+ def type_registry
68
+ return @type_registry if instance_variable_defined?(:@type_registry)
69
+
70
+ @type_registry =
71
+ if superclass < AnywayAppConfig::Config
72
+ superclass.type_registry.dup
73
+ else
74
+ ::Anyway::TypeRegistry.default.dup.tap do |r|
75
+ r.accept(:hash) do |v|
76
+ raise ArgumentError, "expected Hash, got #{v.class}" unless v.is_a?(::Hash)
77
+
78
+ v
79
+ end
80
+ end
81
+ end
82
+ end
83
+
84
+ def type_caster
85
+ @type_caster ||=
86
+ if coercion_mapping.empty?
87
+ fallback_type_caster
88
+ else
89
+ ::Anyway::TypeCaster.new(
90
+ coercion_mapping,
91
+ registry: type_registry,
92
+ fallback: fallback_type_caster
93
+ )
94
+ end
95
+ end
96
+ end
97
+
98
+ def initialize(overrides = nil, config_path: nil, **kwargs)
99
+ @explicit_config_path = calc_explicit_config_path(config_path)
100
+
101
+ if overrides.nil? && !kwargs.empty?
102
+ overrides = kwargs
103
+ elsif !kwargs.empty?
104
+ raise ArgumentError, "unknown keywords: #{kwargs.keys.join(', ')}"
105
+ end
106
+
107
+ super(overrides)
108
+ end
109
+
110
+ def deep_freeze_values!
111
+ deep_freeze_value(values)
112
+ self
113
+ end
114
+
115
+ alias deep_freeze! deep_freeze_values!
116
+
117
+ def load(overrides = nil)
118
+ overrides = overrides.deep_stringify_keys if overrides.is_a?(::Hash)
119
+ super
120
+ end
121
+
122
+ def resolve_config_path(name, env_prefix)
123
+ @explicit_config_path || super
124
+ end
125
+
126
+ private
127
+
128
+ def calc_explicit_config_path(config_path)
129
+ return config_path.to_s unless config_path.nil?
130
+
131
+ class_default = self.class.explicit_config_path
132
+ return if class_default.nil?
133
+
134
+ class_default = class_default.call if class_default.is_a?(Proc)
135
+ class_default&.to_s
136
+ end
137
+
138
+ def deep_freeze_value(val)
139
+ return val if skip_freeze?(val)
140
+
141
+ case val
142
+ when AnywayAppConfig::Config
143
+ val.deep_freeze_values!
144
+ when Array
145
+ val.each { |item| deep_freeze_value(item) }
146
+ val.freeze
147
+ when Hash
148
+ val.each_value { |item| deep_freeze_value(item) }
149
+ val.freeze
150
+ else
151
+ val.freeze if val.respond_to?(:freeze) && !val.frozen?
152
+ end
153
+ val
154
+ end
155
+
156
+ def skip_freeze?(val)
157
+ self.class.skip_freeze_classes.any? { |klass| val.is_a?(klass) }
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'anyway/loaders/yaml'
4
+
5
+ module AnywayAppConfig
6
+ module Loaders
7
+ # YAML loader that always reads the section matching
8
+ # `Anyway::Settings.current_environment`, regardless of file content or
9
+ # global detection. Raises if the current environment is not set.
10
+ class EnvYAML < ::Anyway::Loaders::YAML
11
+ def call(**)
12
+ if ::Anyway::Settings.current_environment.nil?
13
+ raise ArgumentError,
14
+ 'Anyway::Settings.current_environment must be set to use the :env_yml loader'
15
+ end
16
+
17
+ super
18
+ end
19
+
20
+ private
21
+
22
+ def environmental?(_parsed_yml)
23
+ true
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'anyway/loaders/yaml'
4
+
5
+ module AnywayAppConfig
6
+ module Loaders
7
+ # YAML loader that always treats the file as a flat key/value document,
8
+ # ignoring environment sections regardless of file content or global state.
9
+ class FlatYAML < ::Anyway::Loaders::YAML
10
+ private
11
+
12
+ def environmental?(_parsed_yml)
13
+ false
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnywayAppConfig
4
+ class AlreadyLoadedError < StandardError; end
5
+
6
+ module Singleton
7
+ def self.included(base)
8
+ base.extend(ClassMethods)
9
+ end
10
+
11
+ module ClassMethods
12
+ def load!(*, **)
13
+ if instance_variable_defined?(:@instance) && @instance
14
+ raise AlreadyLoadedError, "#{name || self} is already loaded"
15
+ end
16
+
17
+ @instance = new(*, **).tap(&:deep_freeze_values!)
18
+ end
19
+
20
+ def loaded?
21
+ instance_variable_defined?(:@instance) && !@instance.nil?
22
+ end
23
+
24
+ def instance
25
+ return @instance if instance_variable_defined?(:@instance) && @instance
26
+
27
+ raise NotLoadedError, "#{name || self} is not loaded; call #{name || self}.load! first"
28
+ end
29
+
30
+ def respond_to_missing?(method_name, include_private = false)
31
+ (loaded? && @instance.respond_to?(method_name, include_private)) || super
32
+ end
33
+
34
+ def method_missing(method_name, ...)
35
+ if instance.respond_to?(method_name)
36
+ instance.public_send(method_name, ...)
37
+ else
38
+ super
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnywayAppConfig
4
+ VERSION = '0.3.1'
5
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'anyway_app_config/version'
4
+ require_relative 'anyway_app_config/config'
5
+ require_relative 'anyway_app_config/singleton'
6
+ require_relative 'anyway_app_config/loaders/flat_yaml'
7
+ require_relative 'anyway_app_config/loaders/env_yaml'
8
+
9
+ Anyway.loaders.append(:flat_yml, AnywayAppConfig::Loaders::FlatYAML)
10
+ Anyway.loaders.append(:env_yml, AnywayAppConfig::Loaders::EnvYAML)
11
+
12
+ module AnywayAppConfig
13
+ class Error < StandardError; end
14
+
15
+ # Builds an anonymous AnywayAppConfig::Config subclass from a block,
16
+ # for use without a separate file. The block is class_eval'd on the new
17
+ # subclass, so `config_name`, `env_prefix`, and `attribute` are available.
18
+ #
19
+ # Returns the class by default; with `load: true` returns a frozen instance.
20
+ # Anonymous classes have no name, so `config_name` is mandatory in the block.
21
+ def self.build(load: false, &block)
22
+ raise ArgumentError, 'AnywayAppConfig.build requires a block' unless block
23
+
24
+ klass = Class.new(Config)
25
+ klass.class_eval(&block)
26
+ load ? klass.load! : klass
27
+ end
28
+ end
metadata ADDED
@@ -0,0 +1,86 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: anyway_app_config
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.1
5
+ platform: ruby
6
+ authors:
7
+ - Denis Talakevich
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activesupport
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '6.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '6.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: anyway_config
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '2.0'
40
+ description: |
41
+ AnywayAppConfig adds a small DSL on top of anyway_config for describing
42
+ application configs with typed attributes, defaults, required fields and
43
+ nested objects (single or array). Configs can be used as plain instances
44
+ or as singletons, and load values from YAML and ENV via anyway_config.
45
+ email:
46
+ - senid231@gmail.com
47
+ executables: []
48
+ extensions: []
49
+ extra_rdoc_files: []
50
+ files:
51
+ - CODE_OF_CONDUCT.md
52
+ - LICENSE.txt
53
+ - README.md
54
+ - Rakefile
55
+ - lib/anyway_app_config.rb
56
+ - lib/anyway_app_config/config.rb
57
+ - lib/anyway_app_config/loaders/env_yaml.rb
58
+ - lib/anyway_app_config/loaders/flat_yaml.rb
59
+ - lib/anyway_app_config/singleton.rb
60
+ - lib/anyway_app_config/version.rb
61
+ homepage: https://github.com/senid231/anyway_app_config
62
+ licenses:
63
+ - MIT
64
+ metadata:
65
+ homepage_uri: https://github.com/senid231/anyway_app_config
66
+ source_code_uri: https://github.com/senid231/anyway_app_config
67
+ changelog_uri: https://github.com/senid231/anyway_app_config/blob/master/CHANGELOG.md
68
+ rubygems_mfa_required: 'true'
69
+ rdoc_options: []
70
+ require_paths:
71
+ - lib
72
+ required_ruby_version: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: 3.2.0
77
+ required_rubygems_version: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ requirements: []
83
+ rubygems_version: 3.6.9
84
+ specification_version: 4
85
+ summary: Schema-driven application config built on top of anyway_config.
86
+ test_files: []