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,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