super_settings 0.0.0.rc1

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.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +9 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +313 -0
  5. data/VERSION +1 -0
  6. data/app/helpers/super_settings/settings_helper.rb +32 -0
  7. data/app/views/layouts/super_settings/settings.html.erb +20 -0
  8. data/config/routes.rb +13 -0
  9. data/db/migrate/20210414004553_create_super_settings.rb +34 -0
  10. data/lib/super_settings/application/api.js +88 -0
  11. data/lib/super_settings/application/helper.rb +119 -0
  12. data/lib/super_settings/application/images/edit.svg +1 -0
  13. data/lib/super_settings/application/images/info.svg +1 -0
  14. data/lib/super_settings/application/images/plus.svg +1 -0
  15. data/lib/super_settings/application/images/slash.svg +1 -0
  16. data/lib/super_settings/application/images/trash.svg +1 -0
  17. data/lib/super_settings/application/index.html.erb +169 -0
  18. data/lib/super_settings/application/layout.html.erb +22 -0
  19. data/lib/super_settings/application/layout_styles.css +193 -0
  20. data/lib/super_settings/application/scripts.js +718 -0
  21. data/lib/super_settings/application/styles.css +122 -0
  22. data/lib/super_settings/application.rb +38 -0
  23. data/lib/super_settings/attributes.rb +24 -0
  24. data/lib/super_settings/coerce.rb +66 -0
  25. data/lib/super_settings/configuration.rb +144 -0
  26. data/lib/super_settings/controller_actions.rb +81 -0
  27. data/lib/super_settings/encryption.rb +76 -0
  28. data/lib/super_settings/engine.rb +70 -0
  29. data/lib/super_settings/history_item.rb +26 -0
  30. data/lib/super_settings/local_cache.rb +306 -0
  31. data/lib/super_settings/rack_middleware.rb +210 -0
  32. data/lib/super_settings/rest_api.rb +195 -0
  33. data/lib/super_settings/setting.rb +599 -0
  34. data/lib/super_settings/storage/active_record_storage.rb +123 -0
  35. data/lib/super_settings/storage/http_storage.rb +279 -0
  36. data/lib/super_settings/storage/redis_storage.rb +293 -0
  37. data/lib/super_settings/storage/test_storage.rb +158 -0
  38. data/lib/super_settings/storage.rb +254 -0
  39. data/lib/super_settings/version.rb +5 -0
  40. data/lib/super_settings.rb +213 -0
  41. data/lib/tasks/super_settings.rake +9 -0
  42. data/super_settings.gemspec +35 -0
  43. metadata +113 -0
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ # :nocov:
6
+ module SuperSettings
7
+ module Storage
8
+ # Implementation of the SuperSettings::Storage model for running unit tests.
9
+ class TestStorage
10
+ include Storage
11
+
12
+ attr_reader :key, :raw_value, :description, :value_type, :updated_at, :created_at
13
+ attr_accessor :changed_by
14
+
15
+ class << self
16
+ def settings
17
+ @settings ||= {}
18
+ end
19
+
20
+ def history(key)
21
+ @history ||= {}
22
+ items = @history[key]
23
+ unless items
24
+ items = []
25
+ @history[key] = items
26
+ end
27
+ items
28
+ end
29
+
30
+ def clear
31
+ @settings = {}
32
+ @history = {}
33
+ end
34
+
35
+ def all
36
+ settings.values.collect do |attributes|
37
+ setting = new(attributes)
38
+ setting.send(:set_persisted!)
39
+ setting
40
+ end
41
+ end
42
+
43
+ def updated_since(time)
44
+ settings.values.select { |attributes| attributes[:updated_at].to_f >= time.to_f }.collect do |attributes|
45
+ setting = new(attributes)
46
+ setting.send(:set_persisted!)
47
+ setting
48
+ end
49
+ end
50
+
51
+ def find_by_key(key)
52
+ attributes = settings[key]
53
+ return nil unless attributes
54
+ setting = new(attributes)
55
+ setting.send(:set_persisted!)
56
+ setting
57
+ end
58
+
59
+ def last_updated_at
60
+ settings.values.collect { |attributes| attributes[:updated_at] }.max
61
+ end
62
+
63
+ protected
64
+
65
+ def default_load_asynchronous?
66
+ true
67
+ end
68
+ end
69
+
70
+ def history(limit: nil, offset: 0)
71
+ items = self.class.history(key)
72
+ items[offset, limit || items.length].collect do |attributes|
73
+ HistoryItem.new(attributes)
74
+ end
75
+ end
76
+
77
+ def create_history(changed_by:, created_at:, value: nil, deleted: false)
78
+ item = {key: key, value: value, deleted: deleted, changed_by: changed_by, created_at: created_at}
79
+ self.class.history(key).unshift(item)
80
+ end
81
+
82
+ def save!
83
+ self.updated_at ||= Time.now
84
+ self.created_at ||= updated_at
85
+ if defined?(@original_key) && @original_key
86
+ self.class.settings.delete(@original_key)
87
+ end
88
+ self.class.settings[key] = attributes
89
+ set_persisted!
90
+ true
91
+ end
92
+
93
+ def key=(value)
94
+ @original_key ||= key
95
+ @key = (Coerce.blank?(value) ? nil : value.to_s)
96
+ end
97
+
98
+ def raw_value=(value)
99
+ @raw_value = (Coerce.blank?(value) ? nil : value.to_s)
100
+ end
101
+
102
+ def value_type=(value)
103
+ @value_type = (Coerce.blank?(value) ? nil : value.to_s)
104
+ end
105
+
106
+ def description=(value)
107
+ @description = (Coerce.blank?(value) ? nil : value.to_s)
108
+ end
109
+
110
+ def deleted=(value)
111
+ @deleted = Coerce.boolean(value)
112
+ end
113
+
114
+ def created_at=(value)
115
+ @created_at = SuperSettings::Coerce.time(value)
116
+ end
117
+
118
+ def updated_at=(value)
119
+ @updated_at = SuperSettings::Coerce.time(value)
120
+ end
121
+
122
+ def deleted?
123
+ !!(defined?(@deleted) && @deleted)
124
+ end
125
+
126
+ def persisted?
127
+ !!(defined?(@persisted) && @persisted)
128
+ end
129
+
130
+ protected
131
+
132
+ def redact_history!
133
+ self.class.history(key).each do |item|
134
+ item[:value] = nil
135
+ end
136
+ end
137
+
138
+ private
139
+
140
+ def set_persisted!
141
+ @persisted = true
142
+ end
143
+
144
+ def attributes
145
+ {
146
+ key: key,
147
+ raw_value: raw_value,
148
+ value_type: value_type,
149
+ description: description,
150
+ deleted: deleted?,
151
+ updated_at: updated_at,
152
+ created_at: created_at
153
+ }
154
+ end
155
+ end
156
+ end
157
+ end
158
+ # :nocov:
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperSettings
4
+ # Abstraction over how a setting is stored and retrieved from the storage engine. Models
5
+ # must implement the methods module in this module that raise `NotImplementedError`.
6
+ module Storage
7
+ class RecordInvalid < StandardError
8
+ end
9
+
10
+ def self.included(base)
11
+ base.extend(ClassMethods)
12
+ base.include(Attributes) unless base.instance_methods.include?(:attributes=)
13
+ end
14
+
15
+ module ClassMethods
16
+ # Storage classes must implent this method to return all settings included deleted ones.
17
+ # @return [Array<SuperSetting::Setting::Storage>]
18
+ def all
19
+ # :nocov:
20
+ raise NotImplementedError
21
+ # :nocov:
22
+ end
23
+
24
+ # Return all non-deleted settings.
25
+ # @return [Array<SuperSetting::Setting::Storage>]
26
+ def active
27
+ all.reject(&:deleted?)
28
+ end
29
+
30
+ # Storage classes must implement this method to return all settings updates since the
31
+ # specified timestamp.
32
+ # @return [Array<SuperSetting::Setting::Storage>]
33
+ def updated_since(timestamp)
34
+ # :nocov:
35
+ raise NotImplementedError
36
+ # :nocov:
37
+ end
38
+
39
+ # Storage classes must implement this method to return a settings by it's key.
40
+ # @return [SuperSetting::Setting::Storage]
41
+ def find_by_key(key)
42
+ # :nocov:
43
+ raise NotImplementedError
44
+ # :nocov:
45
+ end
46
+
47
+ # Storage classes must implement this method to return most recent time that any
48
+ # setting was updated.
49
+ # @return [Time]
50
+ def last_updated_at
51
+ # :nocov:
52
+ raise NotImplementedError
53
+ # :nocov:
54
+ end
55
+
56
+ # Implementing classes can override this method to setup a thread safe connection within a block.
57
+ def with_connection(&block)
58
+ yield
59
+ end
60
+
61
+ # Implementing classes can override this method to wrap an operation in an atomic transaction.
62
+ def transaction(&block)
63
+ yield
64
+ end
65
+
66
+ # @return [Boolean] true if it's safe to load setting asynchronously in a background thread.
67
+ def load_asynchronous?
68
+ !!(defined?(@load_asynchronous) && !@load_asynchronous.nil? ? @load_asynchronous : default_load_asynchronous?)
69
+ end
70
+
71
+ # Set to true to force loading setting asynchronously in a background thread.
72
+ attr_writer :load_asynchronous
73
+
74
+ protected
75
+
76
+ # Implementing classes can override this method to indicate if it is safe to load the
77
+ # setting in a separate thread.
78
+ def default_load_asynchronous?
79
+ false
80
+ end
81
+ end
82
+
83
+ # @return [String] the key for the setting
84
+ def key
85
+ # :nocov:
86
+ raise NotImplementedError
87
+ # :nocov:
88
+ end
89
+
90
+ # Set the key for the setting.
91
+ # @param val [String]
92
+ # @return [void]
93
+ def key=(val)
94
+ # :nocov:
95
+ raise NotImplementedError
96
+ # :nocov:
97
+ end
98
+
99
+ # @return [String] the raw value for the setting before it is type cast.
100
+ def raw_value
101
+ # :nocov:
102
+ raise NotImplementedError
103
+ # :nocov:
104
+ end
105
+
106
+ # Set the raw value for the setting.
107
+ # @param val [String]
108
+ # @return [void]
109
+ def raw_value=(val)
110
+ # :nocov:
111
+ raise NotImplementedError
112
+ # :nocov:
113
+ end
114
+
115
+ # @return [String] the value type for the setting
116
+ def value_type
117
+ # :nocov:
118
+ raise NotImplementedError
119
+ # :nocov:
120
+ end
121
+
122
+ # Set the value type for the setting.
123
+ # @param val [String] one of string, integer, float, boolean, datetime, array, or secret
124
+ # @return [void]
125
+ def value_type=(val)
126
+ # :nocov:
127
+ raise NotImplementedError
128
+ # :nocov:
129
+ end
130
+
131
+ # @return [String] the description for the setting
132
+ def description
133
+ # :nocov:
134
+ raise NotImplementedError
135
+ # :nocov:
136
+ end
137
+
138
+ # Set the description for the setting.
139
+ # @param val [String]
140
+ # @return [void]
141
+ def description=(val)
142
+ # :nocov:
143
+ raise NotImplementedError
144
+ # :nocov:
145
+ end
146
+
147
+ # @return [Boolean] true if the setting marked as deleted
148
+ def deleted?
149
+ # :nocov:
150
+ raise NotImplementedError
151
+ # :nocov:
152
+ end
153
+
154
+ # Set the deleted flag for the setting. Settings should not actually be deleted since
155
+ # the record is needed to keep the local cache up to date.
156
+ # @param val [Boolean]
157
+ # @return [void]
158
+ def deleted=(val)
159
+ # :nocov:
160
+ raise NotImplementedError
161
+ # :nocov:
162
+ end
163
+
164
+ # @return [Time] the time the setting was last updated
165
+ def updated_at
166
+ # :nocov:
167
+ raise NotImplementedError
168
+ # :nocov:
169
+ end
170
+
171
+ # Set the last updated time for the setting.
172
+ # @param val [Time]
173
+ # @return [void]
174
+ def updated_at=(val)
175
+ # :nocov:
176
+ raise NotImplementedError
177
+ # :nocov:
178
+ end
179
+
180
+ # @return [Time] the time the setting was created
181
+ def created_at
182
+ # :nocov:
183
+ raise NotImplementedError
184
+ # :nocov:
185
+ end
186
+
187
+ # Set the created time for the setting.
188
+ # @param val [Time]
189
+ # @return [void]
190
+ def created_at=(val)
191
+ # :nocov:
192
+ raise NotImplementedError
193
+ # :nocov:
194
+ end
195
+
196
+ # Return array of history items reflecting changes made to the setting over time. Items
197
+ # should be returned in reverse chronological order so that the most recent changes are first.
198
+ # @return [Array<SuperSettings::History>]
199
+ def history(limit: nil, offset: 0)
200
+ # :nocov:
201
+ raise NotImplementedError
202
+ # :nocov:
203
+ end
204
+
205
+ # Create a history item for the setting
206
+ def create_history(changed_by:, created_at:, value: nil, deleted: false)
207
+ # :nocov:
208
+ raise NotImplementedError
209
+ # :nocov:
210
+ end
211
+
212
+ # Persist the record to storage.
213
+ # @return [void]
214
+ def save!
215
+ # :nocov:
216
+ raise NotImplementedError
217
+ # :nocov:
218
+ end
219
+
220
+ # @return [Boolean] true if the record has been stored.
221
+ def persisted?
222
+ # :nocov:
223
+ raise NotImplementedError
224
+ # :nocov:
225
+ end
226
+
227
+ def ==(other)
228
+ other.is_a?(self.class) && other.key == key
229
+ end
230
+
231
+ protected
232
+
233
+ # Remove the value stored on history records if the setting is changed to a secret since
234
+ # these are not stored encrypted in the database. Implementing classes must redefine this
235
+ # method.
236
+ def redact_history!
237
+ # :nocov:
238
+ raise NotImplementedError
239
+ # :nocov:
240
+ end
241
+ end
242
+ end
243
+
244
+ # :nocov:
245
+ require_relative "storage/http_storage"
246
+ require_relative "storage/redis_storage"
247
+ if defined?(ActiveSupport) && ActiveSupport.respond_to?(:on_load)
248
+ ActiveSupport.on_load(:active_record) do
249
+ require_relative "storage/active_record_storage"
250
+ end
251
+ elsif defined?(ActiveRecord::Base)
252
+ require_relative "storage/active_record_storage"
253
+ end
254
+ # :nocov:
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperSettings
4
+ VERSION = File.read(File.expand_path("../../VERSION", __dir__)).chomp
5
+ end
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "secret_keys"
4
+
5
+ require_relative "super_settings/application"
6
+ require_relative "super_settings/coerce"
7
+ require_relative "super_settings/configuration"
8
+ require_relative "super_settings/local_cache"
9
+ require_relative "super_settings/encryption"
10
+ require_relative "super_settings/rest_api"
11
+ require_relative "super_settings/rack_middleware"
12
+ require_relative "super_settings/controller_actions"
13
+ require_relative "super_settings/attributes"
14
+ require_relative "super_settings/setting"
15
+ require_relative "super_settings/history_item"
16
+ require_relative "super_settings/storage"
17
+ require_relative "super_settings/version"
18
+
19
+ if defined?(Rails::Engine)
20
+ require_relative "super_settings/engine"
21
+ end
22
+
23
+ # This is the main interface to the access settings.
24
+ module SuperSettings
25
+ DEFAULT_REFRESH_INTERVAL = 5.0
26
+
27
+ class << self
28
+ # Get a setting value cast to a string.
29
+ #
30
+ # @param key [String, Symbol]
31
+ # @param default [String] value to return if the setting value is nil
32
+ # @return [String]
33
+ def get(key, default = nil)
34
+ val = local_cache[key]
35
+ val.nil? ? default : val.to_s
36
+ end
37
+
38
+ # Get a setting value cast to an integer.
39
+ #
40
+ # @param key [String, Symbol]
41
+ # @param default [Integer] value to return if the setting value is nil
42
+ # @return [Integer]
43
+ def integer(key, default = nil)
44
+ val = local_cache[key]
45
+ (val.nil? ? default : val)&.to_i
46
+ end
47
+
48
+ # Get a setting value cast to a float.
49
+ #
50
+ # @param key [String, Symbol]
51
+ # @param default [Numeric] value to return if the setting value is nil
52
+ # @return [Float]
53
+ def float(key, default = nil)
54
+ val = local_cache[key]
55
+ (val.nil? ? default : val)&.to_f
56
+ end
57
+
58
+ # Get a setting value cast to a boolean.
59
+ #
60
+ # @param key [String, Symbol]
61
+ # @param default [Boolean] value to return if the setting value is nil
62
+ # @return [Boolean]
63
+ def enabled?(key, default = false)
64
+ val = local_cache[key]
65
+ Coerce.boolean(val.nil? ? default : val)
66
+ end
67
+
68
+ # Get a setting value cast to a Time.
69
+ #
70
+ # @param key [String, Symbol]
71
+ # @param default [Time] value to return if the setting value is nil
72
+ # @return [Time]
73
+ def datetime(key, default = nil)
74
+ val = local_cache[key]
75
+ Coerce.time(val.nil? ? default : val)
76
+ end
77
+
78
+ # Get a setting value cast to an array of strings.
79
+ #
80
+ # @param key [String, Symbol]
81
+ # @param default [Array] value to return if the setting value is nil
82
+ # @return [Array]
83
+ def array(key, default = nil)
84
+ val = local_cache[key]
85
+ val = default if val.nil?
86
+ return nil if val.nil?
87
+ Array(val).collect { |v| v&.to_s }
88
+ end
89
+
90
+ # Get setting values cast to a hash. This method can be used to cast the flat setting key/value
91
+ # store into a structured data store. It uses a delimiter to define how keys are nested which
92
+ # defaults to a dot.
93
+ #
94
+ # If, for example, you have three keys in you settings "A.B1.C1 = 1", "A.B1.C2 = 2", and "A.B2.C3 = 3", the
95
+ # nested structure will be:
96
+ #
97
+ # `{"A" => {"B1" => {"C1" => 1, "C2" => 2}, "B2" => {"C3" => 3}}}`
98
+ #
99
+ # This whole hash would be returned if you called `hash` without any key. If you called it with the
100
+ # key "A.B1", it would return `{"C1" => 1, "C2" => 2}`.
101
+ #
102
+ # @param key [String, Symbol] the prefix patter to fetch keys for; default to returning all settings
103
+ # @param default [Hash] value to return if the setting value is nil
104
+ # @param delimiter [String] the delimiter to use to define nested keys in the hash; defaults to "."
105
+ # @return [Hash]
106
+ def structured(key = nil, default = nil, delimiter: ".", max_depth: nil)
107
+ value = local_cache.structured(key, delimiter: delimiter, max_depth: max_depth)
108
+ return (default || {}) if value.empty?
109
+ value
110
+ end
111
+
112
+ # Create settings and update the local cache with the values. If a block is given, then the
113
+ # value will be reverted at the end of the block. This method can be used in tests when you
114
+ # need to inject a specific value into your settings.
115
+ #
116
+ # @param key [String, Symbol] the key to set
117
+ # @param value [Object] the value to set
118
+ # @param value_type [String, Symbol] the value type to set; if the setting does not already exist,
119
+ # this will be inferred from the value.
120
+ # @return [void]
121
+ def set(key, value, value_type: nil)
122
+ setting = Setting.find_by_key(key)
123
+ if setting
124
+ setting.value_type = value_type if value_type
125
+ else
126
+ setting = Setting.new(key: key)
127
+ setting.value_type = (value_type || Setting.value_type(value) || Setting::STRING)
128
+ end
129
+ previous_value = setting.value
130
+ setting.value = value
131
+ begin
132
+ setting.save!
133
+ local_cache.load_settings unless local_cache.loaded?
134
+ local_cache.update_setting(setting)
135
+ if block_given?
136
+ yield
137
+ end
138
+ ensure
139
+ if block_given?
140
+ setting.value = previous_value
141
+ setting.save!
142
+ local_cache.load_settings unless local_cache.loaded?
143
+ local_cache.update_setting(setting)
144
+ end
145
+ end
146
+ end
147
+
148
+ # Load the settings from the database into the in memory cache.
149
+ def load_settings
150
+ local_cache.load_settings
151
+ local_cache.wait_for_load
152
+ nil
153
+ end
154
+
155
+ # Force refresh the settings in the in memory cache to be in sync with the database.
156
+ def refresh_settings
157
+ local_cache.refresh
158
+ nil
159
+ end
160
+
161
+ # Reset the in memory cache. The cache will be automatically reloaded the next time
162
+ # you access a setting.
163
+ def clear_cache
164
+ local_cache.reset
165
+ nil
166
+ end
167
+
168
+ # Return true if the in memory cache has been loaded from the database.
169
+ #
170
+ # @return [Boolean]
171
+ def loaded?
172
+ local_cache.loaded?
173
+ end
174
+
175
+ # Configure various aspects of the gem. The block will be yielded to with a configuration
176
+ # object. You should use this method to configure the gem from an Rails initializer since
177
+ # it will handle ensuring all the appropriate frameworks are loaded first.
178
+ #
179
+ # yieldparam config [SuperSettings::Configuration]
180
+ def configure(&block)
181
+ Configuration.instance.defer(&block)
182
+ unless defined?(Rails::Engine)
183
+ Configuration.instance.call
184
+ end
185
+ end
186
+
187
+ # Set the number of seconds between checks to synchronize the in memory cache from the database.
188
+ # This setting aids in performance since it throttles the number of times the database is queried
189
+ # for changes. However, changes made to the settings in the databae will take up to the number of
190
+ # seconds in the refresh interval to be updated in the cache.
191
+ def refresh_interval=(value)
192
+ local_cache.refresh_interval = value
193
+ end
194
+
195
+ # Set the secret used to encrypt secret settings in the database.
196
+ #
197
+ # If you need to roll your secret, you can pass in an array of values. The first one
198
+ # specified will be used to encrypt values, but all of the keys will be tried when
199
+ # decrypting a value already stored in the database.
200
+ #
201
+ # @param value [String, Array]
202
+ def secret=(value)
203
+ Encryption.secret = value
204
+ load_settings if loaded?
205
+ end
206
+
207
+ private
208
+
209
+ def local_cache
210
+ @local_cache ||= LocalCache.new(refresh_interval: DEFAULT_REFRESH_INTERVAL)
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :super_settings do
4
+ desc "Encrypt settings marked as secret" do
5
+ SuperSettings::Setting.where(value_type: "secret").each do |setting|
6
+ setting.raw_value_will_change! if setting.value
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,35 @@
1
+ Gem::Specification.new do |spec|
2
+ spec.name = "super_settings"
3
+ spec.version = File.read(File.expand_path("../VERSION", __FILE__)).strip
4
+ spec.authors = ["Brian Durand"]
5
+ spec.email = ["bbdurand@gmail.com"]
6
+
7
+ spec.summary = "Fast access runtime settings for a Rails application with an included UI and API for administration."
8
+ spec.homepage = "https://github.com/bdurand/super_settings"
9
+ spec.license = "MIT"
10
+
11
+ # Specify which files should be added to the gem when it is released.
12
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
13
+ ignore_files = %w[
14
+ .
15
+ Appraisals
16
+ Gemfile
17
+ Gemfile.lock
18
+ Rakefile
19
+ bin/
20
+ gemfiles/
21
+ spec/
22
+ web_ui.png
23
+ ]
24
+ spec.files = Dir.chdir(File.expand_path("..", __FILE__)) do
25
+ `git ls-files -z`.split("\x0").reject { |f| ignore_files.any? { |path| f.start_with?(path) } }
26
+ end
27
+
28
+ spec.require_paths = ["lib"]
29
+
30
+ spec.add_dependency "secret_keys", ">= 1.0"
31
+
32
+ spec.add_development_dependency "bundler"
33
+
34
+ spec.required_ruby_version = ">= 2.5"
35
+ end