typed_env 1.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0c7ae6a6af04309a81f2e6eaf57c993e9be108724607186b38582473c6a5eaee
4
+ data.tar.gz: ce9e3a68c4989bd1cc30a7dd3e120c7e13b6f07d4b4851f1574802c3add8b8e7
5
+ SHA512:
6
+ metadata.gz: c8e5554af8efebd63f4170c90d4d4d45e9e468ed6cd9d75afd804ed9483635802ddbb3f83566821483d9dad0131e85914fcb6e1ed004dc63ac90ecee649d3396
7
+ data.tar.gz: a9f03120ccacd3821349baf59d947aa811dc03cd872ff3289c6e2df3a5cdf28186c17abc88eb95102de79fe839c8417a72e6a93bb65ec3e9273f53b77fb867c2
data/CHANGELOG.md ADDED
@@ -0,0 +1,28 @@
1
+ # Changelog
2
+
3
+ All notable changes to this gem are recorded here. The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
4
+ and the versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
+
6
+ ## [1.0.0] - 2026-11-07
7
+
8
+ First release on rubygems.org. Until now the gem was installed from GitHub as `env_settings`, module `EnvSettings`
9
+ (the name is another gem's on rubygems.org): it is `typed_env`, module `TypedEnv`, from this version on —
10
+ `EnvSettings::Base` becomes `TypedEnv::Base`, `EnvSettings::ValidationError` `TypedEnv::ValidationError`.
11
+
12
+ ### Added
13
+
14
+ - The `var` DSL: typed environment variables (`string`, `integer`, `float`, `boolean`, `array`, `hash`, `symbol`) with
15
+ defaults, `name?` for booleans, `name_present?`, `all` / `to_h`.
16
+ - Validations: presence, length, format and inclusion built in; every ActiveModel validation when ActiveModel is loaded.
17
+ `validate!` checks every var; a value written is validated before it is written.
18
+ - Reader and writer callbacks, per var and default for the class: settings kept elsewhere than `ENV`. Read-only
19
+ without a writer.
20
+
21
+ ### Fixed
22
+
23
+ - `validate!` with ActiveModel ran every var's rules N + 1 times (a full validation, then each var again, each time
24
+ all rules); now once.
25
+ - A rule could not read a var declared after it without rules of its own (`if: -> { other }` raised `NameError`).
26
+ - The validator kept a value in an instance variable per setting, which gave its class a shape per setting: Ruby 3.4
27
+ warned "reached 8 shape variations" at boot for more than eight validated vars.
28
+ - README: writer examples used the `key` parameter callbacks no longer receive.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Ekzo
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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,759 @@
1
+ # TypedEnv
2
+
3
+ Type-safe environment variables management for Ruby applications with a clean DSL inspired by rails-settings-cached.
4
+
5
+ ## Features
6
+
7
+ - Type-safe environment variable access
8
+ - Support for multiple types: string, integer, float, boolean, array, hash, symbol
9
+ - Built-in validations (presence, length, format, inclusion)
10
+ - Clean, Rails-like DSL
11
+ - Default values
12
+ - Boolean helper methods
13
+ - Custom reader/writer callbacks for flexible storage (database, Redis, files, etc.)
14
+ - Read-only by default for security
15
+ - ActiveModel validations support (optional)
16
+ - Fully tested
17
+
18
+ ## Installation
19
+
20
+ Add this line to your application's Gemfile:
21
+
22
+ ```ruby
23
+ gem 'typed_env'
24
+ ```
25
+
26
+ And then execute:
27
+
28
+ ```bash
29
+ bundle install
30
+ ```
31
+
32
+ Or install it yourself as:
33
+
34
+ ```bash
35
+ gem install typed_env
36
+ ```
37
+
38
+ ## Usage
39
+
40
+ ### Basic Setup
41
+
42
+ Create a class that inherits from `TypedEnv::Base` and define your environment variables:
43
+
44
+ ```ruby
45
+
46
+ class Env < TypedEnv::Base
47
+ var :app_name, type: :string, default: "MyApp"
48
+ var :port, type: :integer, default: 3000
49
+ var :debug, type: :boolean, default: false
50
+ var :database_url, type: :string, validates: { presence: true }
51
+ var :allowed_hosts, type: :array, default: []
52
+ var :redis_config, type: :hash, default: {}
53
+ end
54
+ ```
55
+
56
+ By default, all variables are **read-only** and read from `ENV`. To enable writing or custom storage, see [Custom Reader/Writer Callbacks](#custom-readerwriter-callbacks).
57
+
58
+ ### Reading Values
59
+
60
+ ```ruby
61
+ # Simple access
62
+ Env.app_name # => "MyApp"
63
+ Env.port # => 3000
64
+
65
+ # Boolean helper
66
+ Env.debug? # => false
67
+
68
+ # Presence check
69
+ Env.database_url_present? # => true/false
70
+
71
+ # Get all settings as hash
72
+ Env.all # => { app_name: "MyApp", port: 3000, ... }
73
+ Env.to_h # Same as .all
74
+ ```
75
+
76
+ ### Setting Values
77
+
78
+ **Important:** By default, all variables are read-only. To enable writing, you must provide a `writer` callback.
79
+
80
+ ```ruby
81
+ # This will raise ReadOnlyError
82
+ Env.app_name = "NewApp" # Raises TypedEnv::ReadOnlyError
83
+
84
+ # To make a variable writable, provide a writer callback
85
+ class Env < TypedEnv::Base
86
+ var :app_name,
87
+ type: :string,
88
+ default: "MyApp",
89
+ writer: ->(value, setting) { ENV[setting[:env_key]] = value.to_s }
90
+ end
91
+
92
+ Env.app_name = "NewApp" # Works
93
+ ```
94
+
95
+ ### Supported Types
96
+
97
+ #### String (default)
98
+
99
+ ```ruby
100
+ var :app_name, type: :string, default: "MyApp"
101
+ # ENV["APP_NAME"] = "MyApp" => "MyApp"
102
+ ```
103
+
104
+ #### Integer
105
+
106
+ ```ruby
107
+ var :port, type: :integer, default: 3000
108
+ # ENV["PORT"] = "5000" => 5000
109
+ ```
110
+
111
+ #### Float
112
+
113
+ ```ruby
114
+ var :price, type: :float, default: 9.99
115
+ # ENV["PRICE"] = "19.99" => 19.99
116
+ ```
117
+
118
+ #### Boolean
119
+
120
+ ```ruby
121
+ var :debug, type: :boolean, default: false
122
+ # ENV["DEBUG"] = "true" => true
123
+ # ENV["DEBUG"] = "1" => true
124
+ # ENV["DEBUG"] = "yes" => true
125
+ # ENV["DEBUG"] = "on" => true
126
+ # ENV["DEBUG"] = "false" => false
127
+ # ENV["DEBUG"] = "0" => false
128
+
129
+ # Boolean helper method
130
+ Env.debug? # => true/false
131
+ ```
132
+
133
+ #### Array
134
+
135
+ ```ruby
136
+ var :allowed_hosts, type: :array, default: []
137
+
138
+ # JSON format
139
+ # ENV["ALLOWED_HOSTS"] = '["host1", "host2"]' => ["host1", "host2"]
140
+
141
+ # Comma-separated format
142
+ # ENV["ALLOWED_HOSTS"] = "host1, host2, host3" => ["host1", "host2", "host3"]
143
+ ```
144
+
145
+ #### Hash
146
+
147
+ ```ruby
148
+ var :redis_config, type: :hash, default: {}
149
+
150
+ # JSON format
151
+ # ENV["REDIS_CONFIG"] = '{"host": "localhost", "port": 6379}'
152
+ # => { "host" => "localhost", "port" => 6379 }
153
+ ```
154
+
155
+ #### Symbol
156
+
157
+ ```ruby
158
+ var :log_level, type: :symbol, default: :info
159
+ # ENV["LOG_LEVEL"] = "debug" => :debug
160
+ ```
161
+
162
+ ### Validations
163
+
164
+ #### Presence
165
+
166
+ ```ruby
167
+ var :database_url, validates: { presence: true }
168
+ ```
169
+
170
+ #### Length
171
+
172
+ ```ruby
173
+ var :username, validates: {
174
+ length: {
175
+ minimum: 3,
176
+ maximum: 20
177
+ }
178
+ }
179
+
180
+ # Or with range
181
+ var :username, validates: {
182
+ length: { in: 3..20 }
183
+ }
184
+ ```
185
+
186
+ #### Format (Regex)
187
+
188
+ ```ruby
189
+ var :email, validates: {
190
+ format: { with: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i }
191
+ }
192
+
193
+ # With custom message
194
+ var :email, validates: {
195
+ format: {
196
+ with: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i,
197
+ message: "must be a valid email address"
198
+ }
199
+ }
200
+ ```
201
+
202
+ #### Inclusion
203
+
204
+ ```ruby
205
+ var :environment, validates: {
206
+ inclusion: { in: %w[development test production] }
207
+ }
208
+ ```
209
+
210
+ #### Multiple Validations
211
+
212
+ ```ruby
213
+ var :api_key, validates: {
214
+ presence: true,
215
+ length: { minimum: 32 },
216
+ format: { with: /\A[a-zA-Z0-9]+\z/ }
217
+ }
218
+ ```
219
+
220
+ ### Running Validations
221
+
222
+ ```ruby
223
+ # Validate all settings at once
224
+ Env.validate!
225
+
226
+ # Will raise TypedEnv::ValidationError if any validation fails
227
+ ```
228
+
229
+ It's recommended to run validations during application initialization:
230
+
231
+ ```ruby
232
+ # config/initializers/typed_env.rb (Rails)
233
+ Env.validate!
234
+ ```
235
+
236
+ ### Automatic Validation on Write
237
+
238
+ When you assign a value to a variable with validations, it's automatically validated **before** writing to storage:
239
+
240
+ ```ruby
241
+ class Env < TypedEnv::Base
242
+ default_writer ->(value, setting) { Setting.find_or_create_by(key: setting[:env_key]).update!(value: value) }
243
+
244
+ var :username,
245
+ type: :string,
246
+ validates: { presence: true, length: { minimum: 3, maximum: 20 } }
247
+
248
+ var :email,
249
+ type: :string,
250
+ validates: { format: { with: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i } }
251
+ end
252
+
253
+ # These will raise ValidationError BEFORE writing to database
254
+ Env.username = "" # Raises: "Username can't be blank"
255
+ Env.username = "ab" # Raises: "Username is too short (minimum is 3 characters)"
256
+ Env.email = "invalid" # Raises: "Email is invalid"
257
+
258
+ # Only valid values are written to storage
259
+ Env.username = "john" # Works - writes to database
260
+ Env.email = "j@example.com" # Works - writes to database
261
+ ```
262
+
263
+ This prevents invalid data from being stored and ensures data integrity at the assignment level.
264
+
265
+ ## ActiveModel Validations (Optional)
266
+
267
+ TypedEnv automatically uses ActiveModel validations if `activemodel` gem is available. This provides:
268
+
269
+ - More validation options (numericality, comparison, exclusion, etc.)
270
+ - Better error messages with I18n support
271
+ - Custom validators
272
+ - Full compatibility with Rails
273
+
274
+ ### Installation with ActiveModel
275
+
276
+ ```ruby
277
+ # Gemfile
278
+ gem 'typed_env'
279
+ gem 'activemodel' # Optional, but recommended for Rails projects
280
+ ```
281
+
282
+ ### ActiveModel Validation Examples
283
+
284
+ ```ruby
285
+ class Env < TypedEnv::Base
286
+ # Numericality validation
287
+ var :port,
288
+ type: :integer,
289
+ default: 3000,
290
+ validates: {
291
+ numericality: {
292
+ only_integer: true,
293
+ greater_than: 0,
294
+ less_than: 65536
295
+ }
296
+ }
297
+
298
+ var :timeout,
299
+ type: :float,
300
+ validates: {
301
+ numericality: { greater_than_or_equal_to: 0 }
302
+ }
303
+
304
+ # Comparison validation
305
+ var :min_value, type: :integer, default: 0
306
+ var :max_value,
307
+ type: :integer,
308
+ default: 100,
309
+ validates: {
310
+ comparison: { greater_than: :min_value }
311
+ }
312
+
313
+ # Exclusion validation
314
+ var :username,
315
+ validates: {
316
+ exclusion: { in: %w[admin root superuser] }
317
+ }
318
+
319
+ # Absence validation (for deprecated variables)
320
+ var :legacy_option,
321
+ validates: { absence: true }
322
+
323
+ # Custom validators
324
+ var :api_endpoint,
325
+ validates: { url: true } # Uses custom UrlValidator
326
+ end
327
+ ```
328
+
329
+ ### Validation Syntax
330
+
331
+ TypedEnv uses **ActiveModel-compatible validation syntax**. This means:
332
+
333
+ - **With ActiveModel** (`activemodel` gem installed): Full ActiveModel validations with rich features
334
+ - **Without ActiveModel**: Built-in simple validations with the same syntax but limited to: `presence`, `length`, `format`, `inclusion`
335
+
336
+ The syntax is identical in both cases, so your code works seamlessly:
337
+
338
+ ```ruby
339
+ # This syntax works with AND without activemodel
340
+ var :email, validates: {
341
+ presence: true,
342
+ format: { with: /regex/, message: "is not valid" }
343
+ }
344
+
345
+ # ActiveModel-only validators (requires activemodel gem)
346
+ var :age, validates: {
347
+ numericality: { greater_than: 0, less_than: 150 } # Only with ActiveModel
348
+ }
349
+
350
+ var :password, validates: {
351
+ confirmation: true # Only with ActiveModel
352
+ }
353
+ ```
354
+
355
+ **Recommendation**: Install `activemodel` gem for Rails projects to get full validation features.
356
+
357
+ ### Rails Integration
358
+
359
+ Create an initializer:
360
+
361
+ ```ruby
362
+ # config/initializers/var.rb
363
+ class Env < TypedEnv::Base
364
+ var :app_name, type: :string, default: "MyRailsApp"
365
+ var :port, type: :integer, default: 3000
366
+ var :database_url, type: :string, validates: { presence: true }
367
+ var :redis_url, type: :string, default: "redis://localhost:6379/0"
368
+ var :smtp_host, type: :string
369
+ var :smtp_port, type: :integer, default: 587
370
+ var :enable_cache, type: :boolean, default: false
371
+ var :allowed_hosts, type: :array, default: []
372
+ var :environment, type: :string, validates: {
373
+ inclusion: %w[development test staging production]
374
+ }
375
+ end
376
+
377
+ # Validate on startup
378
+ Env.validate!
379
+ ```
380
+
381
+ Then use throughout your application:
382
+
383
+ ```ruby
384
+ # config/database.yml
385
+ default: &default
386
+ url: <%= Env.database_url %>
387
+
388
+ # config/environments/production.rb
389
+ config.cache_store = :redis_cache_store, { url: Env.redis_url }
390
+
391
+ # Anywhere in your code
392
+ if Env.enable_cache?
393
+ # Do something
394
+ end
395
+ ```
396
+
397
+ ## Custom Reader/Writer Callbacks
398
+
399
+ TypedEnv allows you to define custom callbacks for reading and writing variables, enabling flexible storage backends like databases, Redis, or files.
400
+
401
+ ### Individual Variable Callbacks
402
+
403
+ ```ruby
404
+
405
+ class Env < TypedEnv::Base
406
+ # Read-only from ENV (default behavior)
407
+ var :database_url, type: :string, validates: { presence: true }
408
+
409
+ # Custom reader from database
410
+ var :maintenance_mode,
411
+ type: :boolean,
412
+ default: false,
413
+ reader: ->(setting) { Setting.find_by(key: setting[:env_key])&.value }
414
+
415
+ # Custom reader and writer
416
+ var :feature_flags,
417
+ type: :hash,
418
+ default: {},
419
+ reader: ->(setting) {
420
+ value = Redis.current.get("settings:#{setting[:env_key]}")
421
+ value ? JSON.parse(value) : nil
422
+ },
423
+ writer: ->(value, setting) {
424
+ Redis.current.set("settings:#{setting[:env_key]}", value.to_json)
425
+ }
426
+ end
427
+
428
+ # Usage
429
+ Env.maintenance_mode # Reads from database
430
+ Env.feature_flags = { x: true } # Writes to Redis
431
+ Env.database_url = "new" # Raises ReadOnlyError (no writer)
432
+ ```
433
+
434
+ ### Global Default Callbacks
435
+
436
+ Set default reader/writer for all variables:
437
+
438
+ ```ruby
439
+
440
+ class Env < TypedEnv::Base
441
+ # All variables will use these callbacks by default
442
+ default_reader ->(setting) {
443
+ Setting.find_by(key: setting[:env_key])&.value || ENV[setting[:env_key]]
444
+ }
445
+
446
+ default_writer ->(value, setting) {
447
+ Setting.find_or_create_by(key: setting[:env_key]).update!(value: value)
448
+ }
449
+
450
+ # Now all variables are readable/writable through database
451
+ var :api_key, type: :string, default: "default_key"
452
+ var :timeout, type: :integer, default: 30
453
+
454
+ # Can override for specific variables
455
+ var :secret_key,
456
+ type: :string,
457
+ reader: ->(setting) { ENV[setting[:env_key]] }, # Only from ENV
458
+ writer: nil # Explicitly read-only
459
+ end
460
+
461
+ # Block syntax is also supported
462
+ class Env < TypedEnv::Base
463
+ default_reader do |setting|
464
+ Setting.find_by(key: setting[:env_key])&.value || ENV[setting[:env_key]]
465
+ end
466
+
467
+ default_writer do |value, setting|
468
+ Setting.find_or_create_by(key: setting[:env_key]).update!(value: value)
469
+ end
470
+ end
471
+ ```
472
+
473
+ ### Callback Parameters
474
+
475
+ Callbacks receive the following parameters:
476
+
477
+ - `value` (writer only) - The value being written, already validated
478
+ - `setting` - Full configuration hash with: `:type`, `:default`, `:validates`, `:env_key` (the uppercase ENV key,
479
+ e.g. "API_KEY"), etc.
480
+
481
+ **Reader callback:**
482
+ ```ruby
483
+ reader: ->(setting) {
484
+ # Must return raw value (string/nil)
485
+ # Type coercion is applied automatically
486
+ }
487
+ ```
488
+
489
+ **Writer callback:**
490
+ ```ruby
491
+ writer: ->(value, setting) {
492
+ # Receives the value to write
493
+ # No return value expected
494
+ }
495
+ ```
496
+
497
+ ### Practical Examples
498
+
499
+ #### ActiveRecord Storage
500
+
501
+ ```ruby
502
+
503
+ class Env < TypedEnv::Base
504
+ default_reader ->(setting) {
505
+ Setting.find_by(key: setting[:env_key])&.value || ENV[setting[:env_key]]
506
+ }
507
+
508
+ default_writer ->(value, setting) {
509
+ Setting.find_or_create_by(key: setting[:env_key]).update!(value: value)
510
+ }
511
+
512
+ var :maintenance_mode, type: :boolean, default: false
513
+ var :max_connections, type: :integer, default: 10
514
+ end
515
+ ```
516
+
517
+ #### Redis Storage
518
+
519
+ ```ruby
520
+
521
+ class Env < TypedEnv::Base
522
+ default_reader ->(setting) {
523
+ Redis.current.get("app:settings:#{setting[:env_key]}")
524
+ }
525
+
526
+ default_writer ->(value, setting) {
527
+ Redis.current.set("app:settings:#{setting[:env_key]}", value.to_s)
528
+ }
529
+
530
+ var :rate_limit, type: :integer, default: 100
531
+ var :feature_x_enabled, type: :boolean, default: false
532
+ end
533
+ ```
534
+
535
+ #### YAML File Storage
536
+
537
+ ```ruby
538
+
539
+ class Env < TypedEnv::Base
540
+ SETTINGS_FILE = "config/runtime_settings.yml"
541
+
542
+ default_reader ->(setting) {
543
+ return ENV[setting[:env_key]] unless File.exist?(SETTINGS_FILE)
544
+ YAML.load_file(SETTINGS_FILE)[setting[:env_key]]
545
+ }
546
+
547
+ default_writer ->(value, setting) {
548
+ data = File.exist?(SETTINGS_FILE) ? YAML.load_file(SETTINGS_FILE) : {}
549
+ data[setting[:env_key]] = value.to_s
550
+ File.write(SETTINGS_FILE, data.to_yaml)
551
+ }
552
+
553
+ var :log_level, type: :symbol, default: :info
554
+ end
555
+ ```
556
+
557
+ #### Vault/Secrets Manager
558
+
559
+ ```ruby
560
+
561
+ class Env < TypedEnv::Base
562
+ var :api_key,
563
+ type: :string,
564
+ reader: ->(setting) {
565
+ Vault.logical.read("secret/data/#{setting[:env_key]}")&.data&.dig(:data, :value)
566
+ },
567
+ writer: ->(value, setting) {
568
+ Vault.logical.write("secret/data/#{setting[:env_key]}", data: { value: value })
569
+ }
570
+ end
571
+ ```
572
+
573
+ #### Mixed Strategy
574
+
575
+ ```ruby
576
+
577
+ class Env < TypedEnv::Base
578
+ # Default: read from ENV (no writer = read-only)
579
+ var :database_url, type: :string, validates: { presence: true }
580
+
581
+ # Runtime settings in database
582
+ var :maintenance_mode,
583
+ type: :boolean,
584
+ default: false,
585
+ reader: ->(setting) { Setting.get(setting[:env_key]) },
586
+ writer: ->(value, setting) { Setting.set(setting[:env_key], value) }
587
+
588
+ # Feature flags in Redis
589
+ var :feature_flags,
590
+ type: :hash,
591
+ default: {},
592
+ reader: ->(setting) { JSON.parse(Redis.current.get(setting[:env_key]) || "{}") },
593
+ writer: ->(value, setting) { Redis.current.set(setting[:env_key], value.to_json) }
594
+
595
+ # Secrets in Vault
596
+ var :stripe_secret_key,
597
+ type: :string,
598
+ reader: ->(setting) { Vault.read("secret/#{setting[:env_key]}") }
599
+ # No writer = read-only
600
+ end
601
+ ```
602
+
603
+ ### Additional Use Cases
604
+
605
+ #### Runtime Configuration with Database
606
+
607
+ Settings that can be changed through admin panel without restart:
608
+
609
+ ```ruby
610
+ # ActiveRecord model
611
+ class Setting < ApplicationRecord
612
+ # Table: settings (key:string, value:text)
613
+
614
+ def self.get(key)
615
+ find_by(key: key)&.value
616
+ end
617
+
618
+ def self.set(key, value)
619
+ find_or_create_by(key: key).update!(value: value.to_s)
620
+ end
621
+ end
622
+
623
+ # TypedEnv configuration
624
+ class Env < TypedEnv::Base
625
+ default_reader ->(setting) { Setting.get(setting[:env_key]) || ENV[setting[:env_key]] }
626
+ default_writer ->(value, setting) { Setting.set(setting[:env_key], value) }
627
+
628
+ var :maintenance_mode, type: :boolean, default: false
629
+ var :max_upload_size, type: :integer, default: 10_485_760
630
+ var :feature_x_enabled, type: :boolean, default: false
631
+ end
632
+
633
+ # Usage in admin controller
634
+ class AdminController < ApplicationController
635
+ def toggle_maintenance
636
+ Env.maintenance_mode = params[:enabled]
637
+ redirect_to admin_path, notice: "Maintenance mode updated"
638
+ end
639
+ end
640
+ ```
641
+
642
+ #### Feature Flags with Redis
643
+
644
+ Fast feature flags with minimal latency:
645
+
646
+ ```ruby
647
+ class Env < TypedEnv::Base
648
+ var :feature_flags,
649
+ type: :hash,
650
+ default: {},
651
+ reader: ->(setting) {
652
+ value = Redis.current.get("flags:#{setting[:env_key]}")
653
+ value ? JSON.parse(value) : nil
654
+ },
655
+ writer: ->(value, setting) {
656
+ Redis.current.set("flags:#{setting[:env_key]}", value.to_json)
657
+ }
658
+ end
659
+
660
+ # Usage
661
+ Env.feature_flags = {
662
+ new_ui: true,
663
+ beta_feature: false
664
+ }
665
+
666
+ if Env.feature_flags[:new_ui]
667
+ render :new_design
668
+ else
669
+ render :old_design
670
+ end
671
+ ```
672
+
673
+ #### Caching Expensive Operations
674
+
675
+ ```ruby
676
+ class Env < TypedEnv::Base
677
+ @vault_cache = {}
678
+
679
+ var :secret_key,
680
+ reader: ->(setting) {
681
+ @vault_cache[setting[:env_key]] ||= begin
682
+ secret = Vault.logical.read("secret/#{setting[:env_key]}")
683
+ secret&.data&.dig(:data, :value)
684
+ end
685
+ }
686
+ end
687
+ ```
688
+
689
+ #### Logging Changes
690
+
691
+ ```ruby
692
+ class Env < TypedEnv::Base
693
+ default_writer ->(value, setting) {
694
+ old_value = Setting.get(setting[:env_key])
695
+ Setting.set(setting[:env_key], value)
696
+
697
+ Rails.logger.info(
698
+ "Setting changed: #{setting[:env_key]} = #{value.inspect} (was: #{old_value.inspect})"
699
+ )
700
+ }
701
+ end
702
+ ```
703
+
704
+ ### Error Handling
705
+
706
+ ```ruby
707
+ # ReadOnlyError is raised when trying to write without a writer
708
+ begin
709
+ Env.database_url = "new_url"
710
+ rescue TypedEnv::ReadOnlyError => e
711
+ puts e.message
712
+ # => "Cannot write to 'database_url': variable is read-only. Provide a writer callback to enable writing."
713
+ end
714
+ ```
715
+
716
+ ## Example: Replacing ENV.fetch
717
+
718
+ **Before:**
719
+ ```ruby
720
+ DATABASE_URL = ENV.fetch('DATABASE_URL')
721
+ PORT = ENV.fetch('PORT', 3000).to_i
722
+ DEBUG = ENV.fetch('DEBUG', 'false') == 'true'
723
+ ALLOWED_HOSTS = ENV.fetch('ALLOWED_HOSTS', '').split(',').map(&:strip)
724
+ ```
725
+
726
+ **After:**
727
+
728
+ ```ruby
729
+
730
+ class Env < TypedEnv::Base
731
+ var :database_url, validates: { presence: true }
732
+ var :port, type: :integer, default: 3000
733
+ var :debug, type: :boolean, default: false
734
+ var :allowed_hosts, type: :array, default: []
735
+ end
736
+
737
+ Env.database_url
738
+ Env.port
739
+ Env.debug?
740
+ Env.allowed_hosts
741
+ ```
742
+
743
+ ## Development
744
+
745
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests.
746
+
747
+ ## Testing
748
+
749
+ ```bash
750
+ bundle exec rspec
751
+ ```
752
+
753
+ ## Contributing
754
+
755
+ Bug reports and pull requests are welcome on GitHub at https://github.com/ekzo-dev/typed_env.
756
+
757
+ ## License
758
+
759
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,302 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "active_model"
5
+ rescue LoadError
6
+ # ActiveModel is optional
7
+ end
8
+
9
+ module TypedEnv
10
+ class Error < StandardError; end
11
+ class ValidationError < Error; end
12
+ class ReadOnlyError < Error; end
13
+
14
+ class Base
15
+ class << self
16
+ def var(name, type: :string, default: nil, validates: nil, reader: nil, writer: nil)
17
+ env_key = name.to_s.upcase
18
+
19
+ settings[name] = {
20
+ type: type,
21
+ default: default,
22
+ validates: validates,
23
+ env_key: env_key,
24
+ reader: reader,
25
+ writer: writer
26
+ }
27
+
28
+ # Register ActiveModel validations if available. Every var gets an accessor on the validator once it exists, so a
29
+ # rule of one may read another (`if: -> { other }`) whichever was declared first.
30
+ if defined?(ActiveModel::Validations)
31
+ ensure_validator_class! if validates
32
+ validator_class&.add_attribute(name, self)
33
+ validator_class.validates name, **validates if validates.is_a?(Hash)
34
+ end
35
+
36
+ define_singleton_method(name) do
37
+ get_value(name)
38
+ end
39
+
40
+ define_singleton_method("#{name}=") do |value|
41
+ set_value(name, value)
42
+ end
43
+
44
+ # Define boolean helper method for boolean types
45
+ if type == :boolean
46
+ define_singleton_method("#{name}?") do
47
+ !!get_value(name)
48
+ end
49
+ end
50
+
51
+ # Define presence check
52
+ define_singleton_method("#{name}_present?") do
53
+ value = get_value(name)
54
+ !value.nil? && value != ""
55
+ end
56
+ end
57
+
58
+ def settings
59
+ @settings ||= {}
60
+ end
61
+
62
+ def validator_class
63
+ @validator_class
64
+ end
65
+
66
+ def ensure_validator_class!
67
+ return if @validator_class
68
+
69
+ parent_class = self
70
+ @validator_class = Class.new do
71
+ include ActiveModel::Validations if defined?(ActiveModel::Validations)
72
+ include ActiveModel::Model if defined?(ActiveModel::Model)
73
+
74
+ # Values being validated, kept in one hash rather than an instance variable per setting: an instance
75
+ # gets one value (validate_var!) and every setting a validator of its own, so per-setting variables
76
+ # gave the class a shape per setting, which Ruby warns of from the eighth on.
77
+ def initialize(*)
78
+ @values = {}
79
+ super
80
+ end
81
+
82
+ # Method to add new vars
83
+ def self.add_attribute(name, parent_class)
84
+ # Check if value is set on instance, otherwise read from parent
85
+ define_method(name) do
86
+ @values.key?(name) ? @values[name] : parent_class.get_value(name)
87
+ end
88
+
89
+ define_method("#{name}=") do |value|
90
+ @values[name] = value
91
+ end
92
+ end
93
+
94
+ # Accessors for the settings defined so far; later ones come through add_attribute
95
+ parent_class.settings.keys.each do |key|
96
+ add_attribute(key, parent_class)
97
+ end
98
+
99
+ # For ActiveModel error messages
100
+ def self.model_name
101
+ ActiveModel::Name.new(self, nil, "TypedEnv")
102
+ end
103
+ end
104
+ end
105
+
106
+ def default_reader(callable = nil, &block)
107
+ @default_reader = callable || block
108
+ end
109
+
110
+ def default_writer(callable = nil, &block)
111
+ @default_writer = callable || block
112
+ end
113
+
114
+ def get_default_reader
115
+ @default_reader
116
+ end
117
+
118
+ def get_default_writer
119
+ @default_writer
120
+ end
121
+
122
+ def get_value(name)
123
+ setting = settings[name]
124
+ return nil unless setting
125
+
126
+ # Use custom reader if provided
127
+ if setting[:reader]
128
+ raw_value = setting[:reader].call(setting)
129
+ elsif get_default_reader
130
+ raw_value = get_default_reader.call(setting)
131
+ else
132
+ # Default behavior: read from ENV
133
+ raw_value = ENV[setting[:env_key]]
134
+ end
135
+
136
+ if raw_value.nil?
137
+ return setting[:default]
138
+ end
139
+
140
+ coerce_value(raw_value, setting[:type])
141
+ end
142
+
143
+ def set_value(name, value)
144
+ setting = settings[name]
145
+ return unless setting
146
+
147
+ # Validate before writing if validations are defined
148
+ validate_var!(name, value) if setting[:validates]
149
+
150
+ # Use custom writer if provided
151
+ if setting[:writer]
152
+ setting[:writer].call(value, setting)
153
+ elsif get_default_writer
154
+ get_default_writer.call(value, setting)
155
+ else
156
+ # Default behavior: raise error (read-only)
157
+ raise ReadOnlyError, "Cannot write to '#{name}': variable is read-only. Provide a writer callback to enable writing."
158
+ end
159
+ end
160
+
161
+ def coerce_value(value, type)
162
+ case type
163
+ when :string
164
+ value.to_s
165
+ when :integer
166
+ value.to_i
167
+ when :float
168
+ value.to_f
169
+ when :boolean
170
+ %w[true 1 yes on].include?(value.to_s.downcase)
171
+ when :array
172
+ parse_array(value)
173
+ when :hash
174
+ parse_hash(value)
175
+ when :symbol
176
+ value.to_sym
177
+ else
178
+ value
179
+ end
180
+ end
181
+
182
+ def parse_array(value)
183
+ return [] if value.nil? || value.empty?
184
+
185
+ # Try to parse as JSON first
186
+ begin
187
+ parsed = JSON.parse(value)
188
+ return parsed if parsed.is_a?(Array)
189
+ rescue JSON::ParserError
190
+ # Fall back to comma-separated
191
+ end
192
+
193
+ value.split(',').map(&:strip)
194
+ end
195
+
196
+ def parse_hash(value)
197
+ return {} if value.nil? || value.empty?
198
+
199
+ begin
200
+ parsed = JSON.parse(value)
201
+ return parsed if parsed.is_a?(Hash)
202
+ rescue JSON::ParserError
203
+ {}
204
+ end
205
+ end
206
+
207
+ def validate_var!(name, value)
208
+ setting = settings[name]
209
+ return unless setting && setting[:validates]
210
+
211
+ validators = setting[:validates]
212
+
213
+ # Use ActiveModel validations if available
214
+ if defined?(ActiveModel::Validations) && validator_class
215
+ # The value being validated in place of the stored one; the others as they are, which rules that read
216
+ # another var need. ActiveModel cannot run one attribute's validators alone and keep their `if:`/`unless:`
217
+ # (`validate(name)` would take `name` for a validation context), so all run and this var's errors are read.
218
+ instance = validator_class.new
219
+ instance.public_send("#{name}=", value)
220
+ instance.valid?
221
+
222
+ attribute_errors = instance.errors[name]
223
+
224
+ if attribute_errors.any?
225
+ raise ValidationError, "#{name.to_s.capitalize.gsub('_', ' ')} #{attribute_errors.join(', ')}"
226
+ end
227
+ return
228
+ end
229
+
230
+ # Run simple validations (when ActiveModel is not available)
231
+ # Presence validation
232
+ if validators[:presence] && (value.nil? || value.to_s.empty?)
233
+ raise ValidationError, "#{name.to_s.capitalize.gsub('_', ' ')} can't be blank"
234
+ end
235
+
236
+ # Length validation
237
+ if validators[:length] && value
238
+ length_opts = validators[:length]
239
+ length = value.to_s.length
240
+
241
+ if length_opts[:minimum] && length < length_opts[:minimum]
242
+ raise ValidationError, "#{name.to_s.capitalize.gsub('_', ' ')} is too short (minimum is #{length_opts[:minimum]} characters)"
243
+ end
244
+
245
+ if length_opts[:maximum] && length > length_opts[:maximum]
246
+ raise ValidationError, "#{name.to_s.capitalize.gsub('_', ' ')} is too long (maximum is #{length_opts[:maximum]} characters)"
247
+ end
248
+
249
+ if length_opts[:in] && !length_opts[:in].include?(length)
250
+ raise ValidationError, "#{name.to_s.capitalize.gsub('_', ' ')} is the wrong length (should be #{length_opts[:in]} characters)"
251
+ end
252
+ end
253
+
254
+ # Format validation
255
+ if validators[:format] && value
256
+ format_opts = validators[:format]
257
+ format_regex = format_opts.is_a?(Hash) ? format_opts[:with] : format_opts
258
+ unless value.to_s.match?(format_regex)
259
+ message = format_opts.is_a?(Hash) && format_opts[:message] ? format_opts[:message] : "is invalid"
260
+ raise ValidationError, "#{name.to_s.capitalize.gsub('_', ' ')} #{message}"
261
+ end
262
+ end
263
+
264
+ # Inclusion validation
265
+ if validators[:inclusion] && value
266
+ inclusion_opts = validators[:inclusion]
267
+ inclusion_values = inclusion_opts.is_a?(Hash) ? inclusion_opts[:in] : inclusion_opts
268
+ unless inclusion_values.include?(value)
269
+ raise ValidationError, "#{name.to_s.capitalize.gsub('_', ' ')} is not included in the list"
270
+ end
271
+ end
272
+ end
273
+
274
+ def validate!
275
+ # With ActiveModel: every var's rules at once, every failure in one error
276
+ if defined?(ActiveModel::Validations) && validator_class
277
+ instance = validator_class.new
278
+ raise ValidationError, instance.errors.full_messages.join(", ") unless instance.valid?
279
+
280
+ return
281
+ end
282
+
283
+ # Without it: the simple validations, var by var
284
+ settings.each do |name, config|
285
+ next unless config[:validates]
286
+
287
+ validate_var!(name, get_value(name))
288
+ end
289
+ end
290
+
291
+ def all
292
+ settings.keys.each_with_object({}) do |name, hash|
293
+ hash[name] = get_value(name)
294
+ end
295
+ end
296
+
297
+ def to_h
298
+ all
299
+ end
300
+ end
301
+ end
302
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedEnv
4
+ VERSION = "1.0.0"
5
+ end
data/lib/typed_env.rb ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "typed_env/version"
5
+ require_relative "typed_env/base"
6
+
7
+ module TypedEnv
8
+ class Error < StandardError; end
9
+ end
data/sig/typed_env.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module TypedEnv
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,51 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: typed_env
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Dmitry Arkhipov
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: TypedEnv provides a clean DSL for managing environment variables with
13
+ type coercion, validations, and default values
14
+ email:
15
+ - d.arkhipov@ekzo.dev
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - CHANGELOG.md
21
+ - LICENSE
22
+ - README.md
23
+ - lib/typed_env.rb
24
+ - lib/typed_env/base.rb
25
+ - lib/typed_env/version.rb
26
+ - sig/typed_env.rbs
27
+ homepage: https://github.com/ekzo-dev/typed_env
28
+ licenses:
29
+ - MIT
30
+ metadata:
31
+ homepage_uri: https://github.com/ekzo-dev/typed_env
32
+ changelog_uri: https://github.com/ekzo-dev/typed_env/blob/main/CHANGELOG.md
33
+ bug_tracker_uri: https://github.com/ekzo-dev/typed_env/issues
34
+ rdoc_options: []
35
+ require_paths:
36
+ - lib
37
+ required_ruby_version: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - ">="
40
+ - !ruby/object:Gem::Version
41
+ version: 3.1.0
42
+ required_rubygems_version: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ requirements: []
48
+ rubygems_version: 3.6.9
49
+ specification_version: 4
50
+ summary: Type-safe environment variables management for Ruby applications
51
+ test_files: []