super_settings 1.0.2 → 2.0.1

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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -0
  3. data/README.md +128 -26
  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 +162 -37
  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
@@ -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