super_settings 1.0.2 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -0
  3. data/README.md +110 -16
  4. data/VERSION +1 -1
  5. data/app/helpers/super_settings/settings_helper.rb +13 -3
  6. data/app/views/layouts/super_settings/settings.html.erb +1 -1
  7. data/config/routes.rb +1 -1
  8. data/db/migrate/20210414004553_create_super_settings.rb +1 -7
  9. data/lib/super_settings/application/api.js +4 -1
  10. data/lib/super_settings/application/helper.rb +56 -17
  11. data/lib/super_settings/application/images/arrow-down-short.svg +3 -0
  12. data/lib/super_settings/application/images/arrow-up-short.svg +3 -0
  13. data/lib/super_settings/application/images/info-circle.svg +4 -0
  14. data/lib/super_settings/application/images/pencil-square.svg +4 -0
  15. data/lib/super_settings/application/images/plus.svg +3 -1
  16. data/lib/super_settings/application/images/trash3.svg +3 -0
  17. data/lib/super_settings/application/images/x-circle.svg +4 -0
  18. data/lib/super_settings/application/index.html.erb +54 -37
  19. data/lib/super_settings/application/layout.html.erb +5 -2
  20. data/lib/super_settings/application/layout_styles.css +7 -151
  21. data/lib/super_settings/application/layout_vars.css.erb +21 -0
  22. data/lib/super_settings/application/scripts.js +100 -21
  23. data/lib/super_settings/application/style_vars.css.erb +62 -0
  24. data/lib/super_settings/application/styles.css +183 -14
  25. data/lib/super_settings/application.rb +18 -11
  26. data/lib/super_settings/attributes.rb +1 -8
  27. data/lib/super_settings/configuration.rb +9 -0
  28. data/lib/super_settings/controller_actions.rb +2 -2
  29. data/lib/super_settings/engine.rb +1 -1
  30. data/lib/super_settings/history_item.rb +1 -1
  31. data/lib/super_settings/http_client.rb +165 -0
  32. data/lib/super_settings/rack_application.rb +3 -3
  33. data/lib/super_settings/rest_api.rb +5 -4
  34. data/lib/super_settings/setting.rb +13 -2
  35. data/lib/super_settings/storage/active_record_storage.rb +7 -0
  36. data/lib/super_settings/storage/history_attributes.rb +31 -0
  37. data/lib/super_settings/storage/http_storage.rb +60 -184
  38. data/lib/super_settings/storage/json_storage.rb +201 -0
  39. data/lib/super_settings/storage/mongodb_storage.rb +238 -0
  40. data/lib/super_settings/storage/redis_storage.rb +49 -111
  41. data/lib/super_settings/storage/s3_storage.rb +165 -0
  42. data/lib/super_settings/storage/storage_attributes.rb +64 -0
  43. data/lib/super_settings/storage/test_storage.rb +3 -5
  44. data/lib/super_settings/storage/transaction.rb +67 -0
  45. data/lib/super_settings/storage.rb +13 -6
  46. data/lib/super_settings/time_precision.rb +36 -0
  47. data/lib/super_settings.rb +11 -0
  48. data/super_settings.gemspec +4 -2
  49. metadata +22 -9
  50. data/lib/super_settings/application/images/edit.svg +0 -1
  51. data/lib/super_settings/application/images/info.svg +0 -1
  52. data/lib/super_settings/application/images/slash.svg +0 -1
  53. data/lib/super_settings/application/images/trash.svg +0 -1
@@ -109,6 +109,7 @@ module SuperSettings
109
109
  # @param time [Time]
110
110
  # @return [Array<Setting>]
111
111
  def updated_since(time)
112
+ time = SuperSettings::Coerce.time(time)
112
113
  storage.with_connection do
113
114
  storage.updated_since(time).collect { |record| new(record) }
114
115
  end
@@ -219,6 +220,16 @@ module SuperSettings
219
220
  next if Coerce.blank?(setting_params["key"])
220
221
  next if ["value_type", "value", "description", "deleted"].all? { |name| Coerce.blank?(setting_params[name]) }
221
222
 
223
+ key_was = setting_params["key_was"]
224
+ if key_was && !changed.include?(key_was)
225
+ old_setting = Setting.find_by_key(key_was)
226
+ if old_setting
227
+ old_setting.deleted = true
228
+ old_setting.changed_by = changed_by
229
+ changed[old_setting.key] = old_setting
230
+ end
231
+ end
232
+
222
233
  key = setting_params["key"]
223
234
  setting = changed[key] || Setting.find_by_key(key)
224
235
  unless setting
@@ -372,7 +383,7 @@ module SuperSettings
372
383
  #
373
384
  # @param val [Time, DateTime]
374
385
  def created_at=(val)
375
- val = Coerce.time(val)
386
+ val = TimePrecision.new(val).time
376
387
  will_change!(:created_at, val) unless created_at == val
377
388
  @record.created_at = val
378
389
  end
@@ -388,7 +399,7 @@ module SuperSettings
388
399
  #
389
400
  # @param val [Time, DateTime]
390
401
  def updated_at=(val)
391
- val = Coerce.time(val)
402
+ val = TimePrecision.new(val).time
392
403
  will_change!(:updated_at, val) unless updated_at == val
393
404
  @record.updated_at = val
394
405
  end
@@ -60,6 +60,13 @@ module SuperSettings
60
60
  Model.transaction(&block)
61
61
  end
62
62
 
63
+ def destroy_all
64
+ ApplicationRecord.transaction do
65
+ Model.delete_all
66
+ HistoryModel.delete_all
67
+ end
68
+ end
69
+
63
70
  protected
64
71
 
65
72
  # Only load settings asynchronously if there is an extra database connection left in the
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperSettings
4
+ module Storage
5
+ # Generic class that can be extended to represent a history record for a setting in memory.
6
+ class HistoryAttributes
7
+ include SuperSettings::Attributes
8
+
9
+ attr_accessor :key, :value, :changed_by
10
+ attr_writer :deleted
11
+ attr_reader :created_at
12
+
13
+ def initialize(*)
14
+ @key = nil
15
+ @value = nil
16
+ @changed_by = nil
17
+ @created_at = nil
18
+ @deleted = false
19
+ super
20
+ end
21
+
22
+ def created_at=(val)
23
+ @created_at = TimePrecision.new(val).time
24
+ end
25
+
26
+ def deleted?
27
+ !!@deleted
28
+ end
29
+ end
30
+ end
31
+ end
@@ -1,59 +1,51 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
- require "net/http"
5
-
6
3
  module SuperSettings
7
4
  module Storage
8
5
  # SuperSettings::Storage model that reads from a remote service running the SuperSettings REST API.
9
6
  # This storage engine is read only. It is intended to allow microservices to read settings from a
10
7
  # central application that exposes the SuperSettings::RestAPI.
11
- class HttpStorage
8
+ #
9
+ # You must the the base_url class attribute to the base URL of a SuperSettings REST API endpoint.
10
+ # You can also set the timeout, headers, and query_params used in reqeusts to the API.
11
+ class HttpStorage < StorageAttributes
12
12
  include Storage
13
+ include Transaction
13
14
 
14
15
  DEFAULT_HEADERS = {"Accept" => "application/json"}.freeze
15
16
  DEFAULT_TIMEOUT = 5.0
16
17
 
18
+ @base_url = nil
19
+ @timeout = nil
17
20
  @headers = {}
18
21
  @query_params = {}
22
+ @http_client = nil
23
+ @http_client_hash = nil
19
24
 
20
- attr_reader :key, :raw_value, :description, :value_type, :updated_at, :created_at
21
-
22
- class Error < StandardError
23
- end
24
-
25
- class NotFoundError < Error
25
+ class HistoryStorage < HistoryAttributes
26
26
  end
27
27
 
28
- class InvalidRecordError < Error
29
- attr_reader :errors
30
-
31
- def initialize(message, errors:)
32
- super(message)
33
- @errors = errors
34
- end
35
- end
36
-
37
- class HistoryStorage
38
- include SuperSettings::Attributes
39
-
40
- attr_accessor :key, :value, :changed_by, :deleted
28
+ class << self
29
+ # Set the base URL for the SuperSettings REST API.
30
+ attr_accessor :base_url
41
31
 
42
- def initialize(*)
43
- @deleted = false
44
- super
45
- end
32
+ # Set the timeout for requests to the SuperSettings REST API.
33
+ attr_accessor :timeout
46
34
 
47
- def created_at=(val)
48
- @created_at = SuperSettings::Coerce.time(val)
49
- end
35
+ # Add headers to this hash to add them to all requests to the SuperSettings REST API.
36
+ #
37
+ # @example
38
+ #
39
+ # SuperSettings::HttpStorage.headers["Authorization"] = "Bearer 12345"
40
+ attr_reader :headers
50
41
 
51
- def deleted?
52
- !!@deleted
53
- end
54
- end
42
+ # Add query parameters to this hash to add them to all requests to the SuperSettings REST API.
43
+ #
44
+ # @example
45
+ #
46
+ # SuperSettings::HttpStorage.query_params["access_token"] = "12345"
47
+ attr_reader :query_params
55
48
 
56
- class << self
57
49
  def all
58
50
  call_api(:get, "/settings")["settings"].collect do |attributes|
59
51
  new(attributes)
@@ -68,9 +60,9 @@ module SuperSettings
68
60
 
69
61
  def find_by_key(key)
70
62
  record = new(call_api(:get, "/setting", key: key))
71
- record.send(:set_persisted!)
63
+ record.persisted = true
72
64
  record
73
- rescue NotFoundError
65
+ rescue HttpClient::NotFoundError
74
66
  nil
75
67
  end
76
68
 
@@ -79,13 +71,30 @@ module SuperSettings
79
71
  SuperSettings::Coerce.time(value)
80
72
  end
81
73
 
82
- attr_accessor :base_url
74
+ def save_all(changes)
75
+ payload = []
76
+ changes.each do |setting|
77
+ setting_payload = {key: setting.key}
83
78
 
84
- attr_accessor :timeout
79
+ if setting.deleted?
80
+ setting_payload[:deleted] = true
81
+ else
82
+ setting_payload[:value] = setting.value
83
+ setting_payload[:value_type] = setting.value_type
84
+ setting_payload[:description] = setting.description
85
+ end
85
86
 
86
- attr_reader :headers
87
+ payload << setting_payload
88
+ end
87
89
 
88
- attr_reader :query_params
90
+ begin
91
+ call_api(:post, "/settings", settings: payload)
92
+ rescue HttpClient::InvalidRecordError
93
+ return false
94
+ end
95
+
96
+ true
97
+ end
89
98
 
90
99
  protected
91
100
 
@@ -96,115 +105,21 @@ module SuperSettings
96
105
  private
97
106
 
98
107
  def call_api(method, path, params = {})
99
- url_params = ((method == :get) ? query_params.merge(params) : query_params)
100
- uri = api_uri(path, url_params)
101
-
102
- body = nil
103
- request_headers = DEFAULT_HEADERS.merge(headers)
104
- if method == :post && !params&.empty?
105
- body = params.to_json
106
- request_headers["content-type"] = "application/json; charset=utf8-"
107
- end
108
-
109
- response = http_request(method: method, uri: uri, headers: request_headers, body: body)
110
-
111
- begin
112
- response.value # raises exception unless response is a success
113
- JSON.parse(response.body)
114
- rescue Net::ProtocolError
115
- if [404, 410].include?(response.code.to_i)
116
- raise NotFoundError.new("#{response.code} #{response.message}")
117
- elsif response.code.to_i == 422
118
- raise InvalidRecordError.new("#{response.code} #{response.message}", errors: JSON.parse(response.body)["errors"])
119
- else
120
- raise Error.new("#{response.code} #{response.message}")
121
- end
122
- rescue JSON::JSONError => e
123
- raise Error.new(e.message)
108
+ if method == :post
109
+ http_client.post(path, params)
110
+ else
111
+ http_client.get(path, params)
124
112
  end
125
113
  end
126
114
 
127
- def http_request(method:, uri:, headers: {}, body: nil, redirect_count: 0)
128
- response = nil
129
- http = Net::HTTP.new(uri.host, uri.port || uri.inferred_port)
130
- begin
131
- http.read_timeout = (timeout || DEFAULT_TIMEOUT)
132
- http.open_timeout = (timeout || DEFAULT_TIMEOUT)
133
- if uri.scheme == "https"
134
- http.use_ssl = true
135
- http.verify_mode = OpenSSL::SSL::VERIFY_NONE
136
- end
137
-
138
- request = ((method == :post) ? Net::HTTP::Post.new(uri.request_uri) : Net::HTTP::Get.new(uri.request_uri))
139
- set_headers(request, headers)
140
- request.body = body if body
141
-
142
- response = http.request(request)
143
- ensure
144
- begin
145
- http.finish if http.started?
146
- rescue IOError
147
- end
148
- end
149
-
150
- if response.is_a?(Net::HTTPRedirection)
151
- location = resp["location"]
152
- if redirect_count < 5 && SuperSettings::Coerce.present?(location)
153
- return http_request(method: :get, uri: URI(location), headers: headers, body: body, redirect_count: redirect_count + 1)
154
- end
155
- end
156
-
157
- response
158
- end
159
-
160
- def api_uri(path, params)
161
- uri = URI("#{base_url.chomp("/")}#{path}")
162
- if params && !params.empty?
163
- q = []
164
- q << uri.query unless uri.query.to_s.empty?
165
- params.each do |name, value|
166
- q << "#{URI.encode_www_form_component(name.to_s)}=#{URI.encode_www_form_component(value.to_s)}"
167
- end
168
- uri.query = q.join("&")
115
+ def http_client
116
+ hash = [base_url, timeout, headers, query_params].hash
117
+ if @http_client.nil? || @http_client_hash != hash
118
+ @http_client = HttpClient.new(base_url, headers: headers, params: query_params, timeout: timeout)
119
+ @http_client_hash = hash
169
120
  end
170
- uri
171
- end
172
-
173
- def set_headers(request, headers)
174
- headers.each do |name, value|
175
- name = name.to_s
176
- values = Array(value)
177
- request[name] = values[0].to_s
178
- values[1, values.length].each do |val|
179
- request.add_field(name, val.to_s)
180
- end
181
- end
182
- end
183
- end
184
-
185
- def initialize(*)
186
- @persisted = false
187
- @deleted = false
188
- super
189
- end
190
-
191
- def save!
192
- payload = {key: key}
193
- if deleted?
194
- payload[:deleted] = true
195
- else
196
- payload[:value] = value
197
- payload[:value_type] = value_type
198
- payload[:description] = description
199
- end
200
-
201
- begin
202
- call_api(:post, "/settings", settings: [payload])
203
- set_persisted!
204
- rescue InvalidRecordError
205
- return false
121
+ @http_client
206
122
  end
207
- true
208
123
  end
209
124
 
210
125
  def history(limit: nil, offset: 0)
@@ -227,50 +142,11 @@ module SuperSettings
227
142
  self
228
143
  end
229
144
 
230
- def key=(value)
231
- @key = (Coerce.blank?(value) ? nil : value.to_s)
232
- end
233
-
234
- def raw_value=(value)
235
- @raw_value = (Coerce.blank?(value) ? nil : value.to_s)
236
- end
237
145
  alias_method :value=, :raw_value=
238
146
  alias_method :value, :raw_value
239
147
 
240
- def value_type=(value)
241
- @value_type = (Coerce.blank?(value) ? nil : value.to_s)
242
- end
243
-
244
- def description=(value)
245
- @description = (Coerce.blank?(value) ? nil : value.to_s)
246
- end
247
-
248
- def deleted=(value)
249
- @deleted = Coerce.boolean(value)
250
- end
251
-
252
- def created_at=(value)
253
- @created_at = SuperSettings::Coerce.time(value)
254
- end
255
-
256
- def updated_at=(value)
257
- @updated_at = SuperSettings::Coerce.time(value)
258
- end
259
-
260
- def deleted?
261
- !!@deleted
262
- end
263
-
264
- def persisted?
265
- !!@persisted
266
- end
267
-
268
148
  private
269
149
 
270
- def set_persisted!
271
- @persisted = true
272
- end
273
-
274
150
  def call_api(method, path, params = {})
275
151
  self.class.send(:call_api, method, path, params)
276
152
  end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperSettings
4
+ module Storage
5
+ # This is an abstract storage class that provides support for storing settings in a JSON file.
6
+ # The settings are stored in JSON as an array of hashes with each hash representing a setting.
7
+ #
8
+ # Setting history should be stored in separate JSON files per key and are loaded separately
9
+ # from the main settings file.
10
+ #
11
+ # This class can be used as the base for any storage class where the settings are all stored
12
+ # together in a single JSON payload.
13
+ #
14
+ # Subclasses must implement the following methods:
15
+ # - self.all
16
+ # - self.last_updated_at
17
+ # - save!
18
+ class JSONStorage < StorageAttributes
19
+ include Transaction
20
+
21
+ class HistoryStorage < HistoryAttributes
22
+ def created_at=(val)
23
+ super(TimePrecision.new(val).time)
24
+ end
25
+ end
26
+
27
+ class << self
28
+ def all
29
+ parse_settings(settings_json_payload)
30
+ end
31
+
32
+ def updated_since(timestamp)
33
+ all.select { |setting| setting.updated_at > timestamp }
34
+ end
35
+
36
+ def find_by_key(key)
37
+ active.detect { |setting| setting.key == key }
38
+ end
39
+
40
+ def save_all(changes)
41
+ existing = {}
42
+ parse_settings(settings_json_payload).each do |setting|
43
+ existing[setting.key] = setting
44
+ end
45
+
46
+ changes.each do |setting|
47
+ existing[setting.key] = setting
48
+ end
49
+
50
+ settings = existing.values.sort_by(&:key)
51
+ changed_histories = {}
52
+ changes.collect do |setting|
53
+ history = (setting.new_history + setting.history).sort_by(&:created_at).reverse
54
+ changed_histories[setting.key] = history.collect do |history_item|
55
+ {
56
+ value: history_item.value,
57
+ changed_by: history_item.changed_by,
58
+ created_at: history_item.created_at&.iso8601(6),
59
+ deleted: history_item.deleted?
60
+ }
61
+ end
62
+ setting.new_history.clear
63
+ end
64
+
65
+ settings_json = JSON.dump(settings.collect(&:as_json))
66
+ save_settings_json(settings_json)
67
+
68
+ changed_histories.each do |setting_key, setting_history|
69
+ history_json = JSON.dump(setting_history)
70
+ save_history_json(setting_key, history_json)
71
+ end
72
+ end
73
+
74
+ # Heper method to load settings from a JSON string.
75
+ #
76
+ # @param json [String] JSON string to parse.
77
+ # @return [Array<SuperSettings::Storage::JSONStorage>] Array of settings.
78
+ def parse_settings(json)
79
+ return [] if Coerce.blank?(json)
80
+
81
+ JSON.parse(json).collect do |attributes|
82
+ setting = new(
83
+ key: attributes["key"],
84
+ raw_value: attributes["value"],
85
+ description: attributes["description"],
86
+ value_type: attributes["value_type"],
87
+ updated_at: Time.parse(attributes["updated_at"]),
88
+ created_at: Time.parse(attributes["created_at"]),
89
+ deleted: attributes["deleted"]
90
+ )
91
+ setting.persisted = true
92
+ setting
93
+ end
94
+ end
95
+
96
+ protected
97
+
98
+ # Subclasses must implement this method to return the JSON payload containing all of the
99
+ # settings as a string.
100
+ #
101
+ # @return [String] JSON string.
102
+ def settings_json_payload
103
+ # :nocov:
104
+ raise NotImplementedError
105
+ # :nocov:
106
+ end
107
+
108
+ # Subclasses must implement this method to persist the JSON payload containing all of the
109
+ # settings.
110
+ #
111
+ # @param json [String] JSON string to save.
112
+ def save_settings_json(json)
113
+ # :nocov:
114
+ raise NotImplementedError
115
+ # :nocov:
116
+ end
117
+
118
+ # Subclasses must implement this method to persist the JSON payload containing the history
119
+ # records for a setting key.
120
+ #
121
+ # @param key [String] Setting key.
122
+ # @param json [String] JSON string to save.
123
+ # @return [void]
124
+ def save_history_json(key, json)
125
+ # :nocov:
126
+ raise NotImplementedError
127
+ # :nocov:
128
+ end
129
+ end
130
+
131
+ def initialize(*)
132
+ @new_history = []
133
+ super
134
+ end
135
+
136
+ def created_at=(val)
137
+ super(TimePrecision.new(val).time)
138
+ end
139
+
140
+ def updated_at=(val)
141
+ super(TimePrecision.new(val).time)
142
+ end
143
+
144
+ def history(limit: nil, offset: 0)
145
+ history = fetch_history
146
+ limit ||= history.length
147
+ history[offset, limit].collect do |record|
148
+ HistoryItem.new(key: key, value: record.value, changed_by: record.changed_by, created_at: record.created_at, deleted: record.deleted?)
149
+ end
150
+ end
151
+
152
+ def create_history(changed_by:, created_at:, value: nil, deleted: false)
153
+ history = HistoryStorage.new(key: key, value: value, changed_by: changed_by, created_at: created_at, deleted: deleted)
154
+ @new_history.unshift(history)
155
+ history
156
+ end
157
+
158
+ attr_reader :new_history
159
+
160
+ def as_json
161
+ {
162
+ key: key,
163
+ value: raw_value,
164
+ value_type: value_type,
165
+ description: description,
166
+ created_at: created_at&.iso8601(6),
167
+ updated_at: updated_at&.iso8601(6),
168
+ deleted: deleted?
169
+ }
170
+ end
171
+
172
+ protected
173
+
174
+ # Subclasses must implement this method to return the JSON payload containing all of the
175
+ # history records for the setting key. The payload must be an array that contains hashes
176
+ # with the keys "value", "changed_by", "deleted", and "created_at".
177
+ def fetch_history_json
178
+ raise NotImplementedError
179
+ end
180
+
181
+ private
182
+
183
+ def fetch_history
184
+ json = fetch_history_json
185
+ history_payload = Coerce.blank?(json) ? [] : JSON.parse(json)
186
+
187
+ history_items = history_payload.collect do |attributes|
188
+ HistoryStorage.new(
189
+ key: key,
190
+ value: attributes["value"],
191
+ changed_by: attributes["changed_by"],
192
+ created_at: Time.parse(attributes["created_at"]),
193
+ deleted: attributes["deleted"]
194
+ )
195
+ end
196
+
197
+ history_items.sort_by(&:created_at).reverse
198
+ end
199
+ end
200
+ end
201
+ end