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,279 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+
6
+ # SuperSettings::Storage model that reads from a remote service running the SuperSettings REST API.
7
+ # This storage engine is read only. It is intended to allow microservices to read settings from a
8
+ # central application that exposes the SuperSettings::RestAPI.
9
+ module SuperSettings
10
+ module Storage
11
+ class HttpStorage
12
+ include Storage
13
+
14
+ DEFAULT_HEADERS = {"Accept" => "application/json"}.freeze
15
+ DEFAULT_TIMEOUT = 5.0
16
+
17
+ attr_reader :key, :raw_value, :description, :value_type, :updated_at, :created_at
18
+
19
+ class Error < StandardError
20
+ end
21
+
22
+ class NotFoundError < Error
23
+ end
24
+
25
+ class InvalidRecordError < Error
26
+ attr_reader :errors
27
+
28
+ def initialize(message, errors:)
29
+ super(message)
30
+ @errors = errors
31
+ end
32
+ end
33
+
34
+ class HistoryStorage
35
+ include SuperSettings::Attributes
36
+
37
+ attr_accessor :key, :value, :changed_by, :deleted
38
+
39
+ def created_at=(val)
40
+ @created_at = SuperSettings::Coerce.time(val)
41
+ end
42
+
43
+ def deleted?
44
+ !!(defined?(@deleted) && @deleted)
45
+ end
46
+ end
47
+
48
+ class << self
49
+ def all
50
+ call_api(:get, "/settings")["settings"].collect do |attributes|
51
+ new(attributes)
52
+ end
53
+ end
54
+
55
+ def updated_since(time)
56
+ call_api(:get, "/settings/updated_since", time: time)["settings"].collect do |attributes|
57
+ new(attributes)
58
+ end
59
+ end
60
+
61
+ def find_by_key(key)
62
+ record = new(call_api(:get, "/setting", key: key))
63
+ record.send(:set_persisted!)
64
+ record
65
+ rescue NotFoundError
66
+ nil
67
+ end
68
+
69
+ def last_updated_at
70
+ value = call_api(:get, "/settings/last_updated_at")["last_updated_at"]
71
+ SuperSettings::Coerce.time(value)
72
+ end
73
+
74
+ attr_accessor :base_url
75
+
76
+ attr_accessor :timeout
77
+
78
+ def headers
79
+ @headers ||= {}
80
+ end
81
+
82
+ def query_params
83
+ @query_params ||= {}
84
+ end
85
+
86
+ protected
87
+
88
+ def default_load_asynchronous?
89
+ true
90
+ end
91
+
92
+ private
93
+
94
+ def call_api(method, path, params = {})
95
+ url_params = (method == :get ? query_params.merge(params) : query_params)
96
+ uri = api_uri(path, url_params)
97
+
98
+ body = nil
99
+ request_headers = DEFAULT_HEADERS.merge(headers)
100
+ if method == :post && !params&.empty?
101
+ body = params.to_json
102
+ request_headers["Content-Type"] = "application/json; charset=utf8-"
103
+ end
104
+
105
+ response = http_request(method: method, uri: uri, headers: request_headers, body: body)
106
+
107
+ begin
108
+ response.value # raises exception unless response is a success
109
+ JSON.parse(response.body)
110
+ rescue Net::ProtocolError
111
+ if [404, 410].include?(response.code.to_i)
112
+ raise NotFoundError.new("#{response.code} #{response.message}")
113
+ elsif response.code.to_i == 422
114
+ raise InvalidRecordError.new("#{response.code} #{response.message}", errors: JSON.parse(response.body)["errors"])
115
+ else
116
+ raise Error.new("#{response.code} #{response.message}")
117
+ end
118
+ rescue JSON::JSONError => e
119
+ raise Error.new(e.message)
120
+ end
121
+ end
122
+
123
+ def http_request(method:, uri:, headers: {}, body: nil, redirect_count: 0)
124
+ response = nil
125
+ http = Net::HTTP.new(uri.host, uri.port || uri.inferred_port)
126
+ begin
127
+ http.read_timeout = (timeout || DEFAULT_TIMEOUT)
128
+ http.open_timeout = (timeout || DEFAULT_TIMEOUT)
129
+ if uri.scheme == "https"
130
+ http.use_ssl = true
131
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE
132
+ end
133
+
134
+ request = (method == :post ? Net::HTTP::Post.new(uri.request_uri) : Net::HTTP::Get.new(uri.request_uri))
135
+ set_headers(request, headers)
136
+ request.body = body if body
137
+
138
+ response = http.request(request)
139
+ ensure
140
+ begin
141
+ http.finish if http.started?
142
+ rescue IOError
143
+ end
144
+ end
145
+
146
+ if response.is_a?(Net::HTTPRedirection)
147
+ location = resp["Location"]
148
+ if redirect_count < 5 && SuperSettings::Coerce.present?(location)
149
+ return http_request(method: :get, uri: URI(location), headers: headers, body: body, redirect_count: redirect_count + 1)
150
+ end
151
+ end
152
+
153
+ response
154
+ end
155
+
156
+ def api_uri(path, params)
157
+ uri = URI("#{base_url.chomp("/")}#{path}")
158
+ if params && !params.empty?
159
+ q = []
160
+ q << uri.query unless uri.query.to_s.empty?
161
+ params.each do |name, value|
162
+ q << "#{URI.encode_www_form_component(name.to_s)}=#{URI.encode_www_form_component(value.to_s)}"
163
+ end
164
+ uri.query = q.join("&")
165
+ end
166
+ uri
167
+ end
168
+
169
+ def set_headers(request, headers)
170
+ headers.each do |name, value|
171
+ name = name.to_s
172
+ values = Array(value)
173
+ request[name] = values[0].to_s
174
+ values[1, values.length].each do |val|
175
+ request.add_field(name, val.to_s)
176
+ end
177
+ end
178
+ end
179
+ end
180
+
181
+ def save!
182
+ payload = {key: key}
183
+ if deleted?
184
+ payload[:deleted] = true
185
+ else
186
+ payload[:value] = value
187
+ payload[:value_type] = value_type
188
+ payload[:description] = description
189
+ end
190
+
191
+ begin
192
+ call_api(:post, "/settings", settings: [payload])
193
+ set_persisted!
194
+ rescue InvalidRecordError
195
+ return false
196
+ end
197
+ true
198
+ end
199
+
200
+ def history(limit: nil, offset: 0)
201
+ params = {key: key}
202
+ params[:offset] = offset if offset > 0
203
+ params[:limit] = limit if limit
204
+ history = call_api(:get, "/setting/history", params)
205
+ history["histories"].collect do |attributes|
206
+ HistoryItem.new(key: key, value: attributes["value"], changed_by: attributes["changed_by"], created_at: attributes["created_at"], deleted: attributes["deleted"])
207
+ end
208
+ end
209
+
210
+ def create_history(changed_by:, created_at:, value: nil, deleted: false)
211
+ # No-op since history is maintained by the source system.
212
+ end
213
+
214
+ def reload
215
+ self.class.find_by_key(key)
216
+ self.attributes = self.class.find_by_key(key).attributes
217
+ self
218
+ end
219
+
220
+ def key=(value)
221
+ @key = (Coerce.blank?(value) ? nil : value.to_s)
222
+ end
223
+
224
+ def raw_value=(value)
225
+ @raw_value = (Coerce.blank?(value) ? nil : value.to_s)
226
+ end
227
+ alias_method :value=, :raw_value=
228
+ alias_method :value, :raw_value
229
+
230
+ def value_type=(value)
231
+ @value_type = (Coerce.blank?(value) ? nil : value.to_s)
232
+ end
233
+
234
+ def description=(value)
235
+ @description = (Coerce.blank?(value) ? nil : value.to_s)
236
+ end
237
+
238
+ def deleted=(value)
239
+ @deleted = Coerce.boolean(value)
240
+ end
241
+
242
+ def created_at=(value)
243
+ @created_at = SuperSettings::Coerce.time(value)
244
+ end
245
+
246
+ def updated_at=(value)
247
+ @updated_at = SuperSettings::Coerce.time(value)
248
+ end
249
+
250
+ def deleted?
251
+ !!(defined?(@deleted) && @deleted)
252
+ end
253
+
254
+ def persisted?
255
+ !!(defined?(@persisted) && @persisted)
256
+ end
257
+
258
+ protected
259
+
260
+ def redact_history!
261
+ # No-op since history is maintained by the source system.
262
+ end
263
+
264
+ private
265
+
266
+ def set_persisted!
267
+ @persisted = true
268
+ end
269
+
270
+ def call_api(method, path, params = {})
271
+ self.class.send(:call_api, method, path, params)
272
+ end
273
+
274
+ def encrypted=(value)
275
+ # No op; needed for API compatibility
276
+ end
277
+ end
278
+ end
279
+ end
@@ -0,0 +1,293 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ # Redis implementation of the SuperSettings::Storage model.
6
+ #
7
+ # You must define the redis connection to use by setting the redis attribute on the class.
8
+ # This can either be a `Redis` object or a block that yields a `Redis` object. You can use the
9
+ # block form if you need to get the `Redis` object at runtime instead of having a static object.
10
+ #
11
+ # ```ruby
12
+ # SuperSettings::Storage::RedisStorage.redis = Redis.new(url: ENV["REDIS_URL"])
13
+ #
14
+ # SuperSettings::Storage::RedisStorage.redis = lambda { RedisClient.get(:settings) }
15
+ # ```
16
+ #
17
+ # You can also use the [connection_pool]() gem to provide a pool of Redis connecions for
18
+ # a multi-threaded application. The connection_pool gem is not a dependency of this gem,
19
+ # so you would need to add it to your application dependencies to use it.
20
+ #
21
+ # ```ruby
22
+ # SuperSettings::Storage::RedisStorage.redis = ConnectionPool.new(size: 5) { Redis.new(url: ENV["REDIS_URL"]) }
23
+ # ```
24
+ module SuperSettings
25
+ module Storage
26
+ class RedisStorage
27
+ include Storage
28
+
29
+ SETTINGS_KEY = "SuperSettings.settings"
30
+ UPDATED_KEY = "SuperSettings.order_by_updated_at"
31
+
32
+ class HistoryStorage
33
+ HISTORY_KEY_PREFIX = "SuperSettings.history"
34
+
35
+ include SuperSettings::Attributes
36
+
37
+ attr_accessor :key, :value, :changed_by, :deleted
38
+ attr_reader :created_at
39
+
40
+ class << self
41
+ def find_all_by_key(key:, offset: 0, limit: nil)
42
+ end_index = (limit.nil? ? -1 : offset + limit - 1)
43
+ return [] unless end_index >= -1
44
+ payloads = RedisStorage.with_redis { |redis| redis.lrange("#{HISTORY_KEY_PREFIX}.#{key}", offset, end_index) }
45
+ payloads.collect do |json|
46
+ record = new(JSON.parse(json))
47
+ record.key = key.to_s
48
+ record
49
+ end
50
+ end
51
+
52
+ def create!(attributes)
53
+ record = new(attributes)
54
+ record.save!
55
+ record
56
+ end
57
+
58
+ def destroy_all_by_key(key)
59
+ RedisStorage.transaction do |redis|
60
+ redis.del("#{HISTORY_KEY_PREFIX}.#{key}")
61
+ end
62
+ end
63
+
64
+ def redis_key(key)
65
+ "#{HISTORY_KEY_PREFIX}.#{key}"
66
+ end
67
+ end
68
+
69
+ def created_at=(val)
70
+ @created_at = SuperSettings::Coerce.time(val)
71
+ end
72
+
73
+ def save!
74
+ raise ArgumentError.new("Missing key") if Coerce.blank?(key)
75
+ RedisStorage.transaction do |redis|
76
+ redis.lpush(self.class.redis_key(key), payload_json.to_json)
77
+ end
78
+ end
79
+
80
+ def deleted?
81
+ !!(defined?(@deleted) && @deleted)
82
+ end
83
+
84
+ private
85
+
86
+ def payload_json
87
+ payload = {
88
+ value: value,
89
+ changed_by: changed_by,
90
+ created_at: created_at.to_f
91
+ }
92
+ payload[:deleted] = true if deleted?
93
+ payload
94
+ end
95
+ end
96
+
97
+ attr_reader :key, :raw_value, :description, :value_type, :updated_at, :created_at
98
+ attr_accessor :changed_by
99
+
100
+ class << self
101
+ def all
102
+ with_redis do |redis|
103
+ redis.hgetall(SETTINGS_KEY).values.collect { |json| load_from_json(json) }
104
+ end
105
+ end
106
+
107
+ def updated_since(time)
108
+ time = SuperSettings::Coerce.time(time)
109
+ with_redis do |redis|
110
+ min_score = time.to_f
111
+ keys = redis.zrangebyscore(UPDATED_KEY, min_score, "+inf")
112
+ return [] if keys.empty?
113
+
114
+ settings = []
115
+ redis.hmget(SETTINGS_KEY, *keys).each do |json|
116
+ settings << load_from_json(json) if json
117
+ end
118
+ settings
119
+ end
120
+ end
121
+
122
+ def find_by_key(key)
123
+ json = with_redis { |redis| redis.hget(SETTINGS_KEY, key) }
124
+ return nil unless json
125
+ load_from_json(json)
126
+ end
127
+
128
+ def last_updated_at
129
+ result = with_redis { |redis| redis.zrevrange(UPDATED_KEY, 0, 1, withscores: true).first }
130
+ return nil unless result
131
+ Time.at(result[1])
132
+ end
133
+
134
+ def destroy_all
135
+ all.each(&:destroy)
136
+ end
137
+
138
+ attr_writer :redis
139
+
140
+ def with_redis(&block)
141
+ connection = (@redis.is_a?(Proc) ? @redis.call : @redis)
142
+ if defined?(ConnectionPool) && connection.is_a?(ConnectionPool)
143
+ connection.with(&block)
144
+ else
145
+ block.call(connection)
146
+ end
147
+ end
148
+
149
+ def transaction(&block)
150
+ if Thread.current[:super_settings_transaction_redis]
151
+ block.call(Thread.current[:super_settings_transaction_redis])
152
+ else
153
+ begin
154
+ with_redis do |redis|
155
+ redis.multi do |multi_redis|
156
+ Thread.current[:super_settings_transaction_redis] = multi_redis
157
+ Thread.current[:super_settings_transaction_after_commit] = []
158
+ block.call(multi_redis)
159
+ end
160
+ after_commits = Thread.current[:super_settings_transaction_after_commit]
161
+ Thread.current[:super_settings_transaction_after_commit] = nil
162
+ after_commits.each(&:call)
163
+ end
164
+ ensure
165
+ Thread.current[:super_settings_transaction_redis] = nil
166
+ Thread.current[:super_settings_transaction_after_commit] = nil
167
+ end
168
+ end
169
+ end
170
+
171
+ protected
172
+
173
+ def default_load_asynchronous?
174
+ true
175
+ end
176
+
177
+ private
178
+
179
+ def load_from_json(json)
180
+ attributes = JSON.parse(json)
181
+ setting = new(attributes)
182
+ setting.send(:set_persisted!)
183
+ setting
184
+ end
185
+ end
186
+
187
+ def history(limit: nil, offset: 0)
188
+ HistoryStorage.find_all_by_key(key: key, limit: limit, offset: offset).collect do |record|
189
+ HistoryItem.new(key: key, value: record.value, changed_by: record.changed_by, created_at: record.created_at, deleted: record.deleted?)
190
+ end
191
+ end
192
+
193
+ def create_history(changed_by:, created_at:, value: nil, deleted: false)
194
+ HistoryStorage.create!(key: key, value: value, deleted: deleted, changed_by: changed_by, created_at: created_at)
195
+ end
196
+
197
+ def save!
198
+ self.updated_at ||= Time.now
199
+ self.created_at ||= updated_at
200
+ self.class.transaction do |redis|
201
+ redis.hset(SETTINGS_KEY, key, payload_json)
202
+ redis.zadd(UPDATED_KEY, updated_at.to_f, key)
203
+ set_persisted!
204
+ end
205
+ true
206
+ end
207
+
208
+ def destroy
209
+ self.class.transaction do |redis|
210
+ redis.hdel(SETTINGS_KEY, key)
211
+ redis.zrem(UPDATED_KEY, key)
212
+ HistoryStorage.destroy_all_by_key(key)
213
+ end
214
+ end
215
+
216
+ def key=(value)
217
+ @key = (Coerce.blank?(value) ? nil : value.to_s)
218
+ end
219
+
220
+ def raw_value=(value)
221
+ @raw_value = (Coerce.blank?(value) ? nil : value.to_s)
222
+ end
223
+
224
+ def value_type=(value)
225
+ @value_type = (Coerce.blank?(value) ? nil : value.to_s)
226
+ end
227
+
228
+ def description=(value)
229
+ @description = (Coerce.blank?(value) ? nil : value.to_s)
230
+ end
231
+
232
+ def deleted=(value)
233
+ @deleted = Coerce.boolean(value)
234
+ end
235
+
236
+ def created_at=(value)
237
+ @created_at = SuperSettings::Coerce.time(value)
238
+ end
239
+
240
+ def updated_at=(value)
241
+ @updated_at = SuperSettings::Coerce.time(value)
242
+ end
243
+
244
+ def deleted?
245
+ !!(defined?(@deleted) && @deleted)
246
+ end
247
+
248
+ def persisted?
249
+ !!(defined?(@persisted) && @persisted)
250
+ end
251
+
252
+ protected
253
+
254
+ def redact_history!
255
+ after_commit do
256
+ histories = HistoryStorage.find_all_by_key(key: key)
257
+ histories.each { |item| item.value = nil }
258
+ self.class.transaction do
259
+ HistoryStorage.destroy_all_by_key(key)
260
+ histories.reverse.each(&:save!)
261
+ end
262
+ end
263
+ end
264
+
265
+ private
266
+
267
+ def after_commit(&block)
268
+ if Thread.current[:super_settings_transaction_after_commit]
269
+ Thread.current[:super_settings_transaction_after_commit] << block
270
+ else
271
+ block.call
272
+ end
273
+ end
274
+
275
+ def set_persisted!
276
+ @persisted = true
277
+ end
278
+
279
+ def payload_json
280
+ payload = {
281
+ key: key,
282
+ raw_value: raw_value,
283
+ value_type: value_type,
284
+ description: description,
285
+ created_at: created_at.to_f,
286
+ updated_at: updated_at.to_f
287
+ }
288
+ payload[:deleted] = true if deleted?
289
+ payload.to_json
290
+ end
291
+ end
292
+ end
293
+ end