super_settings 0.0.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
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