prefab-cloud-ruby 0.22.0 → 0.23.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/Gemfile.lock +5 -5
- data/VERSION +1 -1
- data/lib/prefab/client.rb +21 -0
- data/lib/prefab/config_client.rb +1 -0
- data/lib/prefab/logger_client.rb +15 -3
- data/prefab-cloud-ruby.gemspec +3 -3
- data/test/test_config_resolver.rb +0 -4
- data/test/test_helper.rb +17 -1
- data/test/test_logger.rb +240 -2
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 55f7405129237177e62c5dfe267782a09825ef548daa1dc4991ab21a66769455
|
4
|
+
data.tar.gz: ec4e47e424a6898f31bcba403293de19d8a70d67a951c3d6a830e5cdbb71156e
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 0e78923408d4bf39a2a73c7e9d90c88274b1da106ff148094c2bcba694195d93eab81d373fe36e404740e0266f06b35d928bc21caa10e85c374fbf3667d055af
|
7
|
+
data.tar.gz: db4052d845d6dbda926579da65af9bececa24c96676b44f0d7af01bfed85e527d4768d22b095867f6ec6ec6d30d8e3975fd6edebf4af4d9ea47d1550a5215fb9
|
data/Gemfile.lock
CHANGED
@@ -30,11 +30,11 @@ GEM
|
|
30
30
|
faraday (>= 0.8, < 2)
|
31
31
|
hashie (~> 3.5, >= 3.5.2)
|
32
32
|
oauth2 (~> 1.0)
|
33
|
-
google-protobuf (3.
|
34
|
-
googleapis-common-protos-types (1.
|
33
|
+
google-protobuf (3.22.2)
|
34
|
+
googleapis-common-protos-types (1.5.0)
|
35
35
|
google-protobuf (~> 3.14)
|
36
|
-
grpc (1.
|
37
|
-
google-protobuf (~> 3.
|
36
|
+
grpc (1.53.0)
|
37
|
+
google-protobuf (~> 3.21)
|
38
38
|
googleapis-common-protos-types (~> 1.0)
|
39
39
|
grpc-tools (1.43.1)
|
40
40
|
hashie (3.6.0)
|
@@ -94,7 +94,7 @@ GEM
|
|
94
94
|
psych (3.3.1)
|
95
95
|
public_suffix (4.0.6)
|
96
96
|
racc (1.6.1)
|
97
|
-
rack (3.0.
|
97
|
+
rack (3.0.6.1)
|
98
98
|
rake (13.0.6)
|
99
99
|
rchardet (1.8.0)
|
100
100
|
rdoc (6.3.3)
|
data/VERSION
CHANGED
@@ -1 +1 @@
|
|
1
|
-
0.
|
1
|
+
0.23.1
|
data/lib/prefab/client.rb
CHANGED
@@ -40,6 +40,18 @@ module Prefab
|
|
40
40
|
channel.destroy
|
41
41
|
end
|
42
42
|
end
|
43
|
+
# start config client
|
44
|
+
config_client
|
45
|
+
end
|
46
|
+
|
47
|
+
def with_log_context(lookup_key, properties)
|
48
|
+
Thread.current[:prefab_log_lookup_key] = lookup_key
|
49
|
+
Thread.current[:prefab_log_properties] = properties
|
50
|
+
|
51
|
+
yield
|
52
|
+
ensure
|
53
|
+
Thread.current[:prefab_log_lookup_key] = nil
|
54
|
+
Thread.current[:prefab_log_properties] = {}
|
43
55
|
end
|
44
56
|
|
45
57
|
def channel
|
@@ -72,6 +84,15 @@ module Prefab
|
|
72
84
|
log_path_collector: log_path_collector)
|
73
85
|
end
|
74
86
|
|
87
|
+
def set_rails_loggers
|
88
|
+
Rails.logger = log
|
89
|
+
ActionView::Base.logger = log
|
90
|
+
ActionController::Base.logger = log
|
91
|
+
ActiveJob::Base.logger = log
|
92
|
+
ActiveRecord::Base.logger = log
|
93
|
+
ActiveStorage.logger = log if defined?(ActiveStorage)
|
94
|
+
end
|
95
|
+
|
75
96
|
def log_internal(level, msg, path = nil)
|
76
97
|
log.log_internal msg, path, nil, level
|
77
98
|
end
|
data/lib/prefab/config_client.rb
CHANGED
@@ -74,6 +74,7 @@ module Prefab
|
|
74
74
|
|
75
75
|
def get(key, default = Prefab::Client::NO_DEFAULT_PROVIDED, properties = {}, lookup_key = nil)
|
76
76
|
value = _get(key, lookup_key, properties)
|
77
|
+
|
77
78
|
value ? Prefab::ConfigValueUnwrapper.unwrap(value, key, properties) : handle_default(key, default)
|
78
79
|
end
|
79
80
|
|
data/lib/prefab/logger_client.rb
CHANGED
@@ -131,14 +131,26 @@ module Prefab
|
|
131
131
|
|
132
132
|
private
|
133
133
|
|
134
|
+
NO_DEFAULT = nil
|
135
|
+
|
134
136
|
# Find the closest match to 'log_level.path' in config
|
135
137
|
def level_of(path)
|
136
|
-
|
138
|
+
properties = Thread.current[:prefab_log_properties] || {}
|
139
|
+
lookup_key = Thread.current[:prefab_log_lookup_key] || nil
|
140
|
+
|
141
|
+
closest_log_level_match = nil
|
142
|
+
|
137
143
|
path.split(SEP).each_with_object([BASE_KEY]) do |n, memo|
|
138
144
|
memo << n
|
139
|
-
val = @config_client.get(memo.join(SEP),
|
145
|
+
val = @config_client.get(memo.join(SEP), NO_DEFAULT, properties, lookup_key)
|
140
146
|
closest_log_level_match = val unless val.nil?
|
141
147
|
end
|
148
|
+
|
149
|
+
if closest_log_level_match.nil?
|
150
|
+
# get the top-level setting or default to WARN
|
151
|
+
closest_log_level_match = @config_client.get(BASE_KEY, :WARN, properties, lookup_key)
|
152
|
+
end
|
153
|
+
|
142
154
|
closest_log_level_match_int = Prefab::LogLevel.resolve(closest_log_level_match)
|
143
155
|
LOG_LEVEL_LOOKUPS[closest_log_level_match_int]
|
144
156
|
end
|
@@ -165,7 +177,7 @@ module Prefab
|
|
165
177
|
# StubConfigClient to be used while config client initializes
|
166
178
|
# since it may log
|
167
179
|
class BootstrappingConfigClient
|
168
|
-
def get(_key, default = nil)
|
180
|
+
def get(_key, default = nil, _properties = {}, _lookup_key = nil)
|
169
181
|
ENV['PREFAB_LOG_CLIENT_BOOTSTRAP_LOG_LEVEL'] ? ENV['PREFAB_LOG_CLIENT_BOOTSTRAP_LOG_LEVEL'].upcase.to_sym : default
|
170
182
|
end
|
171
183
|
end
|
data/prefab-cloud-ruby.gemspec
CHANGED
@@ -2,16 +2,16 @@
|
|
2
2
|
# DO NOT EDIT THIS FILE DIRECTLY
|
3
3
|
# Instead, edit Juwelier::Tasks in Rakefile, and run 'rake gemspec'
|
4
4
|
# -*- encoding: utf-8 -*-
|
5
|
-
# stub: prefab-cloud-ruby 0.
|
5
|
+
# stub: prefab-cloud-ruby 0.23.1 ruby lib
|
6
6
|
|
7
7
|
Gem::Specification.new do |s|
|
8
8
|
s.name = "prefab-cloud-ruby".freeze
|
9
|
-
s.version = "0.
|
9
|
+
s.version = "0.23.1"
|
10
10
|
|
11
11
|
s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version=
|
12
12
|
s.require_paths = ["lib".freeze]
|
13
13
|
s.authors = ["Jeff Dwyer".freeze]
|
14
|
-
s.date = "2023-03-
|
14
|
+
s.date = "2023-03-30"
|
15
15
|
s.description = "RateLimits & Config as a service".freeze
|
16
16
|
s.email = "jdwyer@prefab.cloud".freeze
|
17
17
|
s.extra_rdoc_files = [
|
data/test/test_helper.rb
CHANGED
@@ -43,7 +43,7 @@ class MockConfigClient
|
|
43
43
|
@config_values = config_values
|
44
44
|
end
|
45
45
|
|
46
|
-
def get(key, default = nil)
|
46
|
+
def get(key, default = nil, _, _)
|
47
47
|
@config_values.fetch(key, default)
|
48
48
|
end
|
49
49
|
|
@@ -93,3 +93,19 @@ def new_client(overrides = {})
|
|
93
93
|
|
94
94
|
Prefab::Client.new(options)
|
95
95
|
end
|
96
|
+
|
97
|
+
def string_list(values)
|
98
|
+
Prefab::ConfigValue.new(string_list: Prefab::StringList.new(values: values))
|
99
|
+
end
|
100
|
+
|
101
|
+
def inject_config(client, config)
|
102
|
+
resolver = client.config_client.instance_variable_get('@config_resolver')
|
103
|
+
store = resolver.instance_variable_get('@local_store')
|
104
|
+
|
105
|
+
store[config.key] = { config: config }
|
106
|
+
end
|
107
|
+
|
108
|
+
def inject_project_env_id(client, project_env_id)
|
109
|
+
resolver = client.config_client.instance_variable_get('@config_resolver')
|
110
|
+
resolver.project_env_id = project_env_id
|
111
|
+
end
|
data/test/test_logger.rb
CHANGED
@@ -2,7 +2,22 @@
|
|
2
2
|
|
3
3
|
require 'test_helper'
|
4
4
|
|
5
|
-
class
|
5
|
+
class TestLogger < Minitest::Test
|
6
|
+
TEST_ENV_ID = 2
|
7
|
+
DEFAULT_VALUE = 'FATAL'
|
8
|
+
DEFAULT_ENV_VALUE = 'INFO'
|
9
|
+
DESIRED_VALUE = 'DEBUG'
|
10
|
+
WRONG_ENV_VALUE = 'ERROR'
|
11
|
+
PROJECT_ENV_ID = 1
|
12
|
+
|
13
|
+
DEFAULT_ROW = Prefab::ConfigRow.new(
|
14
|
+
values: [
|
15
|
+
Prefab::ConditionalValue.new(
|
16
|
+
value: Prefab::ConfigValue.new(log_level: DEFAULT_VALUE)
|
17
|
+
)
|
18
|
+
]
|
19
|
+
)
|
20
|
+
|
6
21
|
def setup
|
7
22
|
Prefab::LoggerClient.send(:public, :get_path)
|
8
23
|
Prefab::LoggerClient.send(:public, :get_loc_path)
|
@@ -160,10 +175,226 @@ class TestCLogger < Minitest::Test
|
|
160
175
|
assert_logged io, 'ERROR', 'MY_PROGNAME test.test_logger.test_logging_with_a_progname_and_no_message', 'MY_PROGNAME'
|
161
176
|
end
|
162
177
|
|
178
|
+
def test_logging_with_criteria_on_top_level_key
|
179
|
+
prefix = 'my.own.prefix'
|
180
|
+
|
181
|
+
config = Prefab::Config.new(
|
182
|
+
key: 'log-level',
|
183
|
+
rows: [
|
184
|
+
DEFAULT_ROW,
|
185
|
+
|
186
|
+
# wrong env
|
187
|
+
Prefab::ConfigRow.new(
|
188
|
+
project_env_id: TEST_ENV_ID,
|
189
|
+
values: [
|
190
|
+
Prefab::ConditionalValue.new(
|
191
|
+
criteria: [
|
192
|
+
Prefab::Criterion.new(
|
193
|
+
operator: Prefab::Criterion::CriterionOperator::PROP_IS_ONE_OF,
|
194
|
+
value_to_match: string_list(['hotmail.com', 'gmail.com']),
|
195
|
+
property_name: 'email_suffix'
|
196
|
+
)
|
197
|
+
],
|
198
|
+
value: Prefab::ConfigValue.new(log_level: WRONG_ENV_VALUE)
|
199
|
+
)
|
200
|
+
]
|
201
|
+
),
|
202
|
+
|
203
|
+
# correct env
|
204
|
+
Prefab::ConfigRow.new(
|
205
|
+
project_env_id: PROJECT_ENV_ID,
|
206
|
+
values: [
|
207
|
+
Prefab::ConditionalValue.new(
|
208
|
+
criteria: [
|
209
|
+
Prefab::Criterion.new(
|
210
|
+
operator: Prefab::Criterion::CriterionOperator::PROP_IS_ONE_OF,
|
211
|
+
value_to_match: string_list(['hotmail.com', 'gmail.com']),
|
212
|
+
property_name: 'email_suffix'
|
213
|
+
)
|
214
|
+
],
|
215
|
+
value: Prefab::ConfigValue.new(log_level: DESIRED_VALUE)
|
216
|
+
),
|
217
|
+
Prefab::ConditionalValue.new(
|
218
|
+
value: Prefab::ConfigValue.new(log_level: DEFAULT_ENV_VALUE)
|
219
|
+
)
|
220
|
+
]
|
221
|
+
)
|
222
|
+
]
|
223
|
+
)
|
224
|
+
|
225
|
+
prefab, io = captured_logger(log_prefix: prefix)
|
226
|
+
|
227
|
+
inject_config(prefab, config)
|
228
|
+
inject_project_env_id(prefab, PROJECT_ENV_ID)
|
229
|
+
|
230
|
+
# without any context, the level should be the default for the env (info)
|
231
|
+
prefab.with_log_context(nil, {}) do
|
232
|
+
prefab.log.debug 'Test debug'
|
233
|
+
refute_logged io, 'Test debug'
|
234
|
+
|
235
|
+
prefab.log.info 'Test info'
|
236
|
+
assert_logged io, 'INFO', "#{prefix}.test.test_logger.test_logging_with_criteria_on_top_level_key", 'Test info'
|
237
|
+
|
238
|
+
prefab.log.error 'Test error'
|
239
|
+
assert_logged io, 'ERROR', "#{prefix}.test.test_logger.test_logging_with_criteria_on_top_level_key", 'Test error'
|
240
|
+
end
|
241
|
+
|
242
|
+
reset_io(io)
|
243
|
+
|
244
|
+
# with the wrong context, the level should be the default for the env (info)
|
245
|
+
prefab.with_log_context('user:1234', email_suffix: 'yahoo.com') do
|
246
|
+
prefab.log.debug 'Test debug'
|
247
|
+
refute_logged io, 'Test debug'
|
248
|
+
|
249
|
+
prefab.log.info 'Test info'
|
250
|
+
assert_logged io, 'INFO', "#{prefix}.test.test_logger.test_logging_with_criteria_on_top_level_key", 'Test info'
|
251
|
+
|
252
|
+
prefab.log.error 'Test error'
|
253
|
+
assert_logged io, 'ERROR', "#{prefix}.test.test_logger.test_logging_with_criteria_on_top_level_key", 'Test error'
|
254
|
+
end
|
255
|
+
|
256
|
+
reset_io(io)
|
257
|
+
|
258
|
+
# with the correct context, the level should be the desired value (debug)
|
259
|
+
prefab.with_log_context('user:1234', email_suffix: 'hotmail.com') do
|
260
|
+
prefab.log.debug 'Test debug'
|
261
|
+
assert_logged io, 'DEBUG', "#{prefix}.test.test_logger.test_logging_with_criteria_on_top_level_key", 'Test debug'
|
262
|
+
|
263
|
+
prefab.log.info 'Test info'
|
264
|
+
assert_logged io, 'INFO', "#{prefix}.test.test_logger.test_logging_with_criteria_on_top_level_key", 'Test info'
|
265
|
+
|
266
|
+
prefab.log.error 'Test error'
|
267
|
+
assert_logged io, 'ERROR', "#{prefix}.test.test_logger.test_logging_with_criteria_on_top_level_key", 'Test error'
|
268
|
+
end
|
269
|
+
end
|
270
|
+
|
271
|
+
def test_logging_with_criteria_on_key_path
|
272
|
+
prefix = 'my.own.prefix'
|
273
|
+
|
274
|
+
config = Prefab::Config.new(
|
275
|
+
key: 'log-level.my.own.prefix.test.test_logger',
|
276
|
+
rows: [
|
277
|
+
DEFAULT_ROW,
|
278
|
+
|
279
|
+
# wrong env
|
280
|
+
Prefab::ConfigRow.new(
|
281
|
+
project_env_id: TEST_ENV_ID,
|
282
|
+
values: [
|
283
|
+
Prefab::ConditionalValue.new(
|
284
|
+
criteria: [
|
285
|
+
Prefab::Criterion.new(
|
286
|
+
operator: Prefab::Criterion::CriterionOperator::PROP_IS_ONE_OF,
|
287
|
+
value_to_match: string_list(['hotmail.com', 'gmail.com']),
|
288
|
+
property_name: 'email_suffix'
|
289
|
+
)
|
290
|
+
],
|
291
|
+
value: Prefab::ConfigValue.new(log_level: WRONG_ENV_VALUE)
|
292
|
+
)
|
293
|
+
]
|
294
|
+
),
|
295
|
+
|
296
|
+
# correct env
|
297
|
+
Prefab::ConfigRow.new(
|
298
|
+
project_env_id: PROJECT_ENV_ID,
|
299
|
+
values: [
|
300
|
+
Prefab::ConditionalValue.new(
|
301
|
+
criteria: [
|
302
|
+
Prefab::Criterion.new(
|
303
|
+
operator: Prefab::Criterion::CriterionOperator::PROP_IS_ONE_OF,
|
304
|
+
value_to_match: string_list(['hotmail.com', 'gmail.com']),
|
305
|
+
property_name: 'email_suffix'
|
306
|
+
)
|
307
|
+
],
|
308
|
+
value: Prefab::ConfigValue.new(log_level: DESIRED_VALUE)
|
309
|
+
),
|
310
|
+
|
311
|
+
Prefab::ConditionalValue.new(
|
312
|
+
criteria: [
|
313
|
+
Prefab::Criterion.new(
|
314
|
+
operator: Prefab::Criterion::CriterionOperator::LOOKUP_KEY_IN,
|
315
|
+
value_to_match: string_list(%w[user:4567]),
|
316
|
+
property_name: Prefab::CriteriaEvaluator::LOOKUP_KEY
|
317
|
+
)
|
318
|
+
],
|
319
|
+
value: Prefab::ConfigValue.new(log_level: DESIRED_VALUE)
|
320
|
+
),
|
321
|
+
|
322
|
+
Prefab::ConditionalValue.new(
|
323
|
+
value: Prefab::ConfigValue.new(log_level: DEFAULT_ENV_VALUE)
|
324
|
+
)
|
325
|
+
]
|
326
|
+
)
|
327
|
+
]
|
328
|
+
)
|
329
|
+
|
330
|
+
prefab, io = captured_logger(log_prefix: prefix)
|
331
|
+
|
332
|
+
inject_config(prefab, config)
|
333
|
+
inject_project_env_id(prefab, PROJECT_ENV_ID)
|
334
|
+
|
335
|
+
# without any context, the level should be the default for the env (info)
|
336
|
+
prefab.with_log_context(nil, {}) do
|
337
|
+
prefab.log.debug 'Test debug'
|
338
|
+
refute_logged io, 'Test debug'
|
339
|
+
|
340
|
+
prefab.log.info 'Test info'
|
341
|
+
assert_logged io, 'INFO', "#{prefix}.test.test_logger.test_logging_with_criteria_on_key_path", 'Test info'
|
342
|
+
|
343
|
+
prefab.log.error 'Test error'
|
344
|
+
assert_logged io, 'ERROR', "#{prefix}.test.test_logger.test_logging_with_criteria_on_key_path", 'Test error'
|
345
|
+
end
|
346
|
+
|
347
|
+
reset_io(io)
|
348
|
+
|
349
|
+
# with the wrong context, the level should be the default for the env (info)
|
350
|
+
prefab.with_log_context('user:1234', email_suffix: 'yahoo.com') do
|
351
|
+
prefab.log.debug 'Test debug'
|
352
|
+
refute_logged io, 'Test debug'
|
353
|
+
|
354
|
+
prefab.log.info 'Test info'
|
355
|
+
assert_logged io, 'INFO', "#{prefix}.test.test_logger.test_logging_with_criteria_on_key_path", 'Test info'
|
356
|
+
|
357
|
+
prefab.log.error 'Test error'
|
358
|
+
assert_logged io, 'ERROR', "#{prefix}.test.test_logger.test_logging_with_criteria_on_key_path", 'Test error'
|
359
|
+
end
|
360
|
+
|
361
|
+
reset_io(io)
|
362
|
+
|
363
|
+
# with the correct context, the level should be the desired value (debug)
|
364
|
+
prefab.with_log_context('user:1234', email_suffix: 'hotmail.com') do
|
365
|
+
prefab.log.debug 'Test debug'
|
366
|
+
assert_logged io, 'DEBUG', "#{prefix}.test.test_logger.test_logging_with_criteria_on_key_path", 'Test debug'
|
367
|
+
|
368
|
+
prefab.log.info 'Test info'
|
369
|
+
assert_logged io, 'INFO', "#{prefix}.test.test_logger.test_logging_with_criteria_on_key_path", 'Test info'
|
370
|
+
|
371
|
+
prefab.log.error 'Test error'
|
372
|
+
assert_logged io, 'ERROR', "#{prefix}.test.test_logger.test_logging_with_criteria_on_key_path", 'Test error'
|
373
|
+
end
|
374
|
+
|
375
|
+
reset_io(io)
|
376
|
+
|
377
|
+
# with the correct lookup key
|
378
|
+
prefab.with_log_context('user:4567', email_suffix: 'example.com') do
|
379
|
+
prefab.log.debug 'Test debug'
|
380
|
+
assert_logged io, 'DEBUG', "#{prefix}.test.test_logger.test_logging_with_criteria_on_key_path", 'Test debug'
|
381
|
+
|
382
|
+
prefab.log.info 'Test info'
|
383
|
+
assert_logged io, 'INFO', "#{prefix}.test.test_logger.test_logging_with_criteria_on_key_path", 'Test info'
|
384
|
+
|
385
|
+
prefab.log.error 'Test error'
|
386
|
+
assert_logged io, 'ERROR', "#{prefix}.test.test_logger.test_logging_with_criteria_on_key_path", 'Test error'
|
387
|
+
end
|
388
|
+
end
|
389
|
+
|
163
390
|
private
|
164
391
|
|
165
392
|
def assert_logged(logged_io, level, path, message)
|
166
|
-
assert_match(/#{level}
|
393
|
+
assert_match(/#{level}\s+\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} [-+]?\d+:\s+#{path}: #{message}\n/, logged_io.string)
|
394
|
+
end
|
395
|
+
|
396
|
+
def refute_logged(logged_io, message)
|
397
|
+
refute_match(/#{message}/, logged_io.string)
|
167
398
|
end
|
168
399
|
|
169
400
|
def mock_logger_expecting(pattern, configs = {}, calls: 1)
|
@@ -192,4 +423,11 @@ class TestCLogger < Minitest::Test
|
|
192
423
|
|
193
424
|
[prefab, io]
|
194
425
|
end
|
426
|
+
|
427
|
+
def reset_io(io)
|
428
|
+
io.close
|
429
|
+
io.reopen
|
430
|
+
|
431
|
+
assert_equal '', io.string
|
432
|
+
end
|
195
433
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: prefab-cloud-ruby
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.23.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Jeff Dwyer
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2023-03-
|
11
|
+
date: 2023-03-30 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: concurrent-ruby
|