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 +7 -0
- data/CHANGELOG.md +28 -0
- data/LICENSE +21 -0
- data/README.md +759 -0
- data/lib/typed_env/base.rb +302 -0
- data/lib/typed_env/version.rb +5 -0
- data/lib/typed_env.rb +9 -0
- data/sig/typed_env.rbs +4 -0
- metadata +51 -0
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
|
data/lib/typed_env.rb
ADDED
data/sig/typed_env.rbs
ADDED
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: []
|