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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +28 -0
- data/README.md +128 -26
- data/VERSION +1 -1
- data/app/helpers/super_settings/settings_helper.rb +13 -3
- data/app/views/layouts/super_settings/settings.html.erb +1 -1
- data/config/routes.rb +1 -1
- data/db/migrate/20210414004553_create_super_settings.rb +1 -7
- data/lib/super_settings/application/api.js +4 -1
- data/lib/super_settings/application/helper.rb +56 -17
- data/lib/super_settings/application/images/arrow-down-short.svg +3 -0
- data/lib/super_settings/application/images/arrow-up-short.svg +3 -0
- data/lib/super_settings/application/images/info-circle.svg +4 -0
- data/lib/super_settings/application/images/pencil-square.svg +4 -0
- data/lib/super_settings/application/images/plus.svg +3 -1
- data/lib/super_settings/application/images/trash3.svg +3 -0
- data/lib/super_settings/application/images/x-circle.svg +4 -0
- data/lib/super_settings/application/index.html.erb +54 -37
- data/lib/super_settings/application/layout.html.erb +5 -2
- data/lib/super_settings/application/layout_styles.css +7 -151
- data/lib/super_settings/application/layout_vars.css.erb +21 -0
- data/lib/super_settings/application/scripts.js +162 -37
- data/lib/super_settings/application/style_vars.css.erb +62 -0
- data/lib/super_settings/application/styles.css +183 -14
- data/lib/super_settings/application.rb +18 -11
- data/lib/super_settings/attributes.rb +1 -8
- data/lib/super_settings/configuration.rb +9 -0
- data/lib/super_settings/controller_actions.rb +2 -2
- data/lib/super_settings/engine.rb +1 -1
- data/lib/super_settings/history_item.rb +1 -1
- data/lib/super_settings/http_client.rb +165 -0
- data/lib/super_settings/rack_application.rb +3 -3
- data/lib/super_settings/rest_api.rb +5 -4
- data/lib/super_settings/setting.rb +13 -2
- data/lib/super_settings/storage/active_record_storage.rb +7 -0
- data/lib/super_settings/storage/history_attributes.rb +31 -0
- data/lib/super_settings/storage/http_storage.rb +60 -184
- data/lib/super_settings/storage/json_storage.rb +201 -0
- data/lib/super_settings/storage/mongodb_storage.rb +238 -0
- data/lib/super_settings/storage/redis_storage.rb +49 -111
- data/lib/super_settings/storage/s3_storage.rb +165 -0
- data/lib/super_settings/storage/storage_attributes.rb +64 -0
- data/lib/super_settings/storage/test_storage.rb +3 -5
- data/lib/super_settings/storage/transaction.rb +67 -0
- data/lib/super_settings/storage.rb +13 -6
- data/lib/super_settings/time_precision.rb +36 -0
- data/lib/super_settings.rb +11 -0
- data/super_settings.gemspec +4 -2
- metadata +22 -9
- data/lib/super_settings/application/images/edit.svg +0 -1
- data/lib/super_settings/application/images/info.svg +0 -1
- data/lib/super_settings/application/images/slash.svg +0 -1
- 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
|
-
|
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
|
-
|
21
|
-
|
22
|
-
class Error < StandardError
|
23
|
-
end
|
24
|
-
|
25
|
-
class NotFoundError < Error
|
25
|
+
class HistoryStorage < HistoryAttributes
|
26
26
|
end
|
27
27
|
|
28
|
-
class
|
29
|
-
|
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
|
-
|
43
|
-
|
44
|
-
super
|
45
|
-
end
|
32
|
+
# Set the timeout for requests to the SuperSettings REST API.
|
33
|
+
attr_accessor :timeout
|
46
34
|
|
47
|
-
|
48
|
-
|
49
|
-
|
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
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
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.
|
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
|
-
|
74
|
+
def save_all(changes)
|
75
|
+
payload = []
|
76
|
+
changes.each do |setting|
|
77
|
+
setting_payload = {key: setting.key}
|
83
78
|
|
84
|
-
|
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
|
-
|
87
|
+
payload << setting_payload
|
88
|
+
end
|
87
89
|
|
88
|
-
|
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
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
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
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
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
|
-
|
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
|