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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +9 -0
- data/MIT-LICENSE +20 -0
- data/README.md +313 -0
- data/VERSION +1 -0
- data/app/helpers/super_settings/settings_helper.rb +32 -0
- data/app/views/layouts/super_settings/settings.html.erb +20 -0
- data/config/routes.rb +13 -0
- data/db/migrate/20210414004553_create_super_settings.rb +34 -0
- data/lib/super_settings/application/api.js +88 -0
- data/lib/super_settings/application/helper.rb +119 -0
- data/lib/super_settings/application/images/edit.svg +1 -0
- data/lib/super_settings/application/images/info.svg +1 -0
- data/lib/super_settings/application/images/plus.svg +1 -0
- data/lib/super_settings/application/images/slash.svg +1 -0
- data/lib/super_settings/application/images/trash.svg +1 -0
- data/lib/super_settings/application/index.html.erb +169 -0
- data/lib/super_settings/application/layout.html.erb +22 -0
- data/lib/super_settings/application/layout_styles.css +193 -0
- data/lib/super_settings/application/scripts.js +718 -0
- data/lib/super_settings/application/styles.css +122 -0
- data/lib/super_settings/application.rb +38 -0
- data/lib/super_settings/attributes.rb +24 -0
- data/lib/super_settings/coerce.rb +66 -0
- data/lib/super_settings/configuration.rb +144 -0
- data/lib/super_settings/controller_actions.rb +81 -0
- data/lib/super_settings/encryption.rb +76 -0
- data/lib/super_settings/engine.rb +70 -0
- data/lib/super_settings/history_item.rb +26 -0
- data/lib/super_settings/local_cache.rb +306 -0
- data/lib/super_settings/rack_middleware.rb +210 -0
- data/lib/super_settings/rest_api.rb +195 -0
- data/lib/super_settings/setting.rb +599 -0
- data/lib/super_settings/storage/active_record_storage.rb +123 -0
- data/lib/super_settings/storage/http_storage.rb +279 -0
- data/lib/super_settings/storage/redis_storage.rb +293 -0
- data/lib/super_settings/storage/test_storage.rb +158 -0
- data/lib/super_settings/storage.rb +254 -0
- data/lib/super_settings/version.rb +5 -0
- data/lib/super_settings.rb +213 -0
- data/lib/tasks/super_settings.rake +9 -0
- data/super_settings.gemspec +35 -0
- metadata +113 -0
@@ -0,0 +1,306 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SuperSettings
|
4
|
+
# Cache that stores the settings in memory so they can be looked up without any
|
5
|
+
# network overhead. All of the settings will be loaded in the cache and the database
|
6
|
+
# will only be checked every few seconds for changes so that lookups are very fast.
|
7
|
+
#
|
8
|
+
# The cache is thread safe and it ensures that only a single thread will ever be
|
9
|
+
# trying to update the cache at a time to avoid any dog piling effects.
|
10
|
+
class LocalCache
|
11
|
+
# @private
|
12
|
+
NOT_DEFINED = Object.new.freeze
|
13
|
+
private_constant :NOT_DEFINED
|
14
|
+
|
15
|
+
# @private
|
16
|
+
DRIFT_FACTOR = 10
|
17
|
+
private_constant :DRIFT_FACTOR
|
18
|
+
|
19
|
+
# Number of seconds that the cache will be considered fresh. The database will only be
|
20
|
+
# checked for changed settings at most this often.
|
21
|
+
attr_reader :refresh_interval
|
22
|
+
|
23
|
+
# @parem refresh_interval [Numeric] number of seconds to wait between checking for setting updates
|
24
|
+
def initialize(refresh_interval:)
|
25
|
+
@refresh_interval = refresh_interval
|
26
|
+
@lock = Mutex.new
|
27
|
+
reset
|
28
|
+
end
|
29
|
+
|
30
|
+
# Get a setting value from the cache.
|
31
|
+
#
|
32
|
+
# This method will periodically check the cache for freshness and update the cache from
|
33
|
+
# the database if there are any differences.
|
34
|
+
#
|
35
|
+
# Cache misses will be stored in the cache so that a request for a missing setting does not
|
36
|
+
# hit the database every time. This does mean that that you should not call this method with
|
37
|
+
# a large number of dynamically generated keys since that could lead to memory bloat.
|
38
|
+
#
|
39
|
+
# @param key [String, Symbol] setting key
|
40
|
+
def [](key)
|
41
|
+
ensure_cache_up_to_date!
|
42
|
+
key = key.to_s
|
43
|
+
value = @cache[key]
|
44
|
+
|
45
|
+
if value.nil? && !@cache.include?(key)
|
46
|
+
if @refreshing
|
47
|
+
value = NOT_DEFINED
|
48
|
+
else
|
49
|
+
setting = Setting.find_by_key(key)
|
50
|
+
value = (setting ? setting.value : NOT_DEFINED)
|
51
|
+
# Guard against caching too many cache missees; at some point it's better to slam
|
52
|
+
# the database rather than run out of memory.
|
53
|
+
if setting || @cache.size < 100_000
|
54
|
+
@lock.synchronize do
|
55
|
+
# For case where one thread could be iterating over the cache while it's updated causing an error
|
56
|
+
@cache = @cache.merge(key => value).freeze
|
57
|
+
@hashes = {}
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
return nil if value == NOT_DEFINED
|
64
|
+
value
|
65
|
+
end
|
66
|
+
|
67
|
+
# Return the setting as structured data. The keys will be split by the specified delimiter
|
68
|
+
# to create a nested hash (i.e. "a.b.c" = 1 becomes `{"a" => {"b" => {"c" => 1}}}`).
|
69
|
+
#
|
70
|
+
# See SuperSettings.structured for more details.
|
71
|
+
def structured(key = nil, delimiter: ".", max_depth: nil)
|
72
|
+
key = key.to_s
|
73
|
+
cache_key = [key, delimiter, max_depth]
|
74
|
+
cached_value = @hashes[cache_key]
|
75
|
+
return cached_value if cached_value
|
76
|
+
|
77
|
+
flattened = to_h
|
78
|
+
root_key = ""
|
79
|
+
if Coerce.present?(key)
|
80
|
+
root_key = "#{key}#{delimiter}"
|
81
|
+
reduced_hash = {}
|
82
|
+
flattened.each do |k, v|
|
83
|
+
if k.start_with?(root_key)
|
84
|
+
reduced_hash[k[root_key.length, k.length]] = v
|
85
|
+
end
|
86
|
+
end
|
87
|
+
flattened = reduced_hash
|
88
|
+
end
|
89
|
+
|
90
|
+
structured_hash = {}
|
91
|
+
flattened.each do |key, value|
|
92
|
+
set_nested_hash_value(structured_hash, key, value, 0, delimiter: delimiter, max_depth: max_depth)
|
93
|
+
end
|
94
|
+
|
95
|
+
deep_freeze_hash(structured_hash)
|
96
|
+
@lock.synchronize do
|
97
|
+
@hashes[cache_key] = structured_hash
|
98
|
+
end
|
99
|
+
|
100
|
+
structured_hash
|
101
|
+
end
|
102
|
+
|
103
|
+
# Check if the cache includes a key. Note that this will return true if you have tried
|
104
|
+
# to fetch a non-existent key since the cache will store that as undefined. This method
|
105
|
+
# is provided for testing purposes.
|
106
|
+
#
|
107
|
+
# @api private
|
108
|
+
# @param key [String, Symbol] setting key
|
109
|
+
# @return [Boolean]
|
110
|
+
def include?(key)
|
111
|
+
@cache.include?(key.to_s)
|
112
|
+
end
|
113
|
+
|
114
|
+
# Get the number of entries in the cache. Note that this will include cache misses as well.
|
115
|
+
#
|
116
|
+
# @api private
|
117
|
+
# @return the number of entries in the cache.
|
118
|
+
def size
|
119
|
+
ensure_cache_up_to_date!
|
120
|
+
@cache.size
|
121
|
+
end
|
122
|
+
|
123
|
+
# Return the cached settings as a key/value hash. Calling this method will load the cache
|
124
|
+
# with the settings if they have not already been loaded.
|
125
|
+
#
|
126
|
+
# @return [Hash]
|
127
|
+
def to_h
|
128
|
+
ensure_cache_up_to_date!
|
129
|
+
hash = {}
|
130
|
+
@cache.each do |key, data|
|
131
|
+
value, _ = data
|
132
|
+
hash[key] = value unless value == NOT_DEFINED
|
133
|
+
end
|
134
|
+
hash
|
135
|
+
end
|
136
|
+
|
137
|
+
# Return true if the cache has already been loaded from the database.
|
138
|
+
#
|
139
|
+
# @return [Boolean]
|
140
|
+
def loaded?
|
141
|
+
!!@last_refreshed
|
142
|
+
end
|
143
|
+
|
144
|
+
# Load all the settings from the database into the cache.
|
145
|
+
def load_settings(asynchronous = false)
|
146
|
+
return if @refreshing
|
147
|
+
|
148
|
+
@lock.synchronize do
|
149
|
+
return if @refreshing
|
150
|
+
@refreshing = true
|
151
|
+
@next_check_at = Time.now + @refresh_interval
|
152
|
+
end
|
153
|
+
|
154
|
+
load_block = lambda do
|
155
|
+
begin
|
156
|
+
values = {}
|
157
|
+
start_time = Time.now
|
158
|
+
Setting.active.each do |setting|
|
159
|
+
values[setting.key] = setting.value.freeze
|
160
|
+
end
|
161
|
+
set_cache_values(start_time) { values }
|
162
|
+
ensure
|
163
|
+
@refreshing = false
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
if asynchronous
|
168
|
+
Thread.new(&load_block)
|
169
|
+
else
|
170
|
+
load_block.call
|
171
|
+
end
|
172
|
+
end
|
173
|
+
|
174
|
+
# Load only settings that have changed since the last load.
|
175
|
+
def refresh(asynchronous = false)
|
176
|
+
last_refresh_time = @last_refreshed
|
177
|
+
return if last_refresh_time.nil?
|
178
|
+
return if @refreshing
|
179
|
+
|
180
|
+
@lock.synchronize do
|
181
|
+
return if @refreshing
|
182
|
+
@next_check_at = Time.now + @refresh_interval
|
183
|
+
return if @cache.empty?
|
184
|
+
@refreshing = true
|
185
|
+
end
|
186
|
+
|
187
|
+
refresh_block = lambda do
|
188
|
+
begin
|
189
|
+
last_db_update = Setting.last_updated_at
|
190
|
+
if last_db_update.nil? || last_db_update >= last_refresh_time - 1
|
191
|
+
merge_load(last_refresh_time)
|
192
|
+
end
|
193
|
+
ensure
|
194
|
+
@refreshing = false
|
195
|
+
end
|
196
|
+
end
|
197
|
+
|
198
|
+
if asynchronous
|
199
|
+
Thread.new(&refresh_block)
|
200
|
+
else
|
201
|
+
refresh_block.call
|
202
|
+
end
|
203
|
+
end
|
204
|
+
|
205
|
+
# Reset the cache to an unloaded state.
|
206
|
+
def reset
|
207
|
+
@lock.synchronize do
|
208
|
+
@cache = {}.freeze
|
209
|
+
@hashes = {}
|
210
|
+
@last_refreshed = nil
|
211
|
+
@next_check_at = Time.now + @refresh_interval
|
212
|
+
@refreshing = false
|
213
|
+
end
|
214
|
+
end
|
215
|
+
|
216
|
+
# Set the number of seconds to wait between cache refresh checks.
|
217
|
+
#
|
218
|
+
# @param seconds [Numeric]
|
219
|
+
def refresh_interval=(seconds)
|
220
|
+
@lock.synchronize do
|
221
|
+
@refresh_interval = seconds.to_f
|
222
|
+
@next_check_at = Time.now + @refresh_interval if @next_check_at > Time.now + @refresh_interval
|
223
|
+
end
|
224
|
+
end
|
225
|
+
|
226
|
+
# Update a single setting directly into the cache.
|
227
|
+
# @api private
|
228
|
+
def update_setting(setting)
|
229
|
+
return if Coerce.blank?(setting.key)
|
230
|
+
@lock.synchronize do
|
231
|
+
@cache = @cache.merge(setting.key => setting.value)
|
232
|
+
@hashes = {}
|
233
|
+
end
|
234
|
+
end
|
235
|
+
|
236
|
+
# Wait for the settings to be loaded if a new load was triggered. This can happen asynchronously.
|
237
|
+
# @api private
|
238
|
+
def wait_for_load
|
239
|
+
loop do
|
240
|
+
return unless @refreshing
|
241
|
+
sleep(0.001)
|
242
|
+
end
|
243
|
+
end
|
244
|
+
|
245
|
+
private
|
246
|
+
|
247
|
+
# Load just the settings have that changed since the specified timestamp.
|
248
|
+
def merge_load(last_refresh_time)
|
249
|
+
changed_settings = {}
|
250
|
+
start_time = Time.now
|
251
|
+
Setting.updated_since(last_refresh_time - 1).each do |setting|
|
252
|
+
value = (setting.deleted? ? NOT_DEFINED : setting.value)
|
253
|
+
changed_settings[setting.key] = value
|
254
|
+
end
|
255
|
+
set_cache_values(start_time) { @cache.merge(changed_settings) }
|
256
|
+
end
|
257
|
+
|
258
|
+
# Check that cache has update to date data in it. If it doesn't, then sync the
|
259
|
+
# cache with the database.
|
260
|
+
def ensure_cache_up_to_date!
|
261
|
+
if @last_refreshed.nil?
|
262
|
+
# Abort if another thread is already calling load_settings
|
263
|
+
previous_cache_id = @cache.object_id
|
264
|
+
@lock.synchronize do
|
265
|
+
return unless previous_cache_id == @cache.object_id
|
266
|
+
end
|
267
|
+
load_settings(Setting.storage.load_asynchronous?)
|
268
|
+
elsif Time.now >= @next_check_at
|
269
|
+
refresh
|
270
|
+
end
|
271
|
+
end
|
272
|
+
|
273
|
+
# Synchronized method for setting cache and sync meta data.
|
274
|
+
def set_cache_values(refreshed_at_time, &block)
|
275
|
+
@lock.synchronize do
|
276
|
+
@last_refreshed = refreshed_at_time
|
277
|
+
@refreshing = false
|
278
|
+
@cache = block.call.freeze
|
279
|
+
@hashes = {}
|
280
|
+
end
|
281
|
+
end
|
282
|
+
|
283
|
+
# Recusive method for creating a nested hash from delimited keys.
|
284
|
+
def set_nested_hash_value(hash, key, value, current_depth, delimiter:, max_depth:)
|
285
|
+
key, sub_key = (max_depth && current_depth < max_depth ? [key, nil] : key.split(delimiter, 2))
|
286
|
+
if sub_key
|
287
|
+
sub_hash = hash[key]
|
288
|
+
unless sub_hash.is_a?(Hash)
|
289
|
+
sub_hash = {}
|
290
|
+
hash[key] = sub_hash
|
291
|
+
end
|
292
|
+
set_nested_hash_value(sub_hash, sub_key, value, current_depth + 1, delimiter: delimiter, max_depth: max_depth)
|
293
|
+
else
|
294
|
+
hash[key] = value
|
295
|
+
end
|
296
|
+
end
|
297
|
+
|
298
|
+
# Recursively freeze a hash.
|
299
|
+
def deep_freeze_hash(hash)
|
300
|
+
hash.each_value do |value|
|
301
|
+
deep_freeze_hash(value) if value.is_a?(Hash)
|
302
|
+
end
|
303
|
+
hash.freeze
|
304
|
+
end
|
305
|
+
end
|
306
|
+
end
|
@@ -0,0 +1,210 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rack"
|
4
|
+
|
5
|
+
module SuperSettings
|
6
|
+
# Rack middleware for serving the REST API. See SuperSettings::RestAPI for more details on usage.
|
7
|
+
#
|
8
|
+
# The routes for the API can be mounted under a common path prefix specified in the initializer.
|
9
|
+
#
|
10
|
+
# You must specify some kind of authentication to use this class by at least overriding the
|
11
|
+
# `authenticated?` method in a subclass. How you do this is left up to you since you will most
|
12
|
+
# likely want to integrate in with how the rest of your application authenticates requests.
|
13
|
+
#
|
14
|
+
# You are also responsible for implementing any CSRF protection if your authentication method
|
15
|
+
# uses stateful requests (i.e. cookies or Basic auth where browser automatically include the
|
16
|
+
# credentials on every reqeust). There are other gems available that can be integrated into
|
17
|
+
# your middleware stack to provide this feature. If you need to inject meta elements into
|
18
|
+
# the page, you can do so with the `add_to_head` method.
|
19
|
+
class RackMiddleware
|
20
|
+
RESPONSE_HEADERS = {"Content-Type" => "application/json; charset=utf-8"}.freeze
|
21
|
+
|
22
|
+
# @param app [Object] Rack application or middleware for unhandled requests
|
23
|
+
# @param prefix [String] path prefix for the API routes.
|
24
|
+
def initialize(app, path_prefix = "/")
|
25
|
+
@app = app
|
26
|
+
@path_prefix = path_prefix.to_s.chomp("/")
|
27
|
+
end
|
28
|
+
|
29
|
+
def call(env)
|
30
|
+
if @path_prefix.empty? || "#{env["SCRIPT_NAME"]}#{env["PATH_INFO"]}".start_with?(@path_prefix)
|
31
|
+
handle_request(env)
|
32
|
+
else
|
33
|
+
@app.call(env)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
protected
|
38
|
+
|
39
|
+
# Subclasses must implement this method.
|
40
|
+
# @param user [Object] the value returned by the `current_user` method.
|
41
|
+
# @return [Boolean] true if the user is authenticated.
|
42
|
+
def authenticated?(user)
|
43
|
+
raise NotImplementedError
|
44
|
+
end
|
45
|
+
|
46
|
+
# Subclasses can override this method to indicate if the specified user is allowed to view settings.
|
47
|
+
# @param user [Object] the value returned by the `current_user` method.
|
48
|
+
# @return [Boolean] true if the user is can view settings.
|
49
|
+
def allow_read?(user)
|
50
|
+
true
|
51
|
+
end
|
52
|
+
|
53
|
+
# Subclasses can override this method to indicate if the specified user is allowed to change settings.
|
54
|
+
# @param user [Object] the value returned by the `current_user` method.
|
55
|
+
# @return [Boolean] true if the user is can change settings.
|
56
|
+
def allow_write?(user)
|
57
|
+
true
|
58
|
+
end
|
59
|
+
|
60
|
+
# Subclasses can override this method to return the current user object. This object will
|
61
|
+
# be passed to the authenticated?, allow_read?, allow_write?, and changed_by methods.
|
62
|
+
# @param request [Rack::Request] current reqeust object
|
63
|
+
# @return [Object]
|
64
|
+
def current_user(request)
|
65
|
+
nil
|
66
|
+
end
|
67
|
+
|
68
|
+
# Subclasses can override this method to return the information about the current user that will
|
69
|
+
# be stored in the setting history when a setting is changed.
|
70
|
+
# @return [String]
|
71
|
+
def changed_by(user)
|
72
|
+
nil
|
73
|
+
end
|
74
|
+
|
75
|
+
# Subclasses can override this method to return the path to an ERB file to use as the layout
|
76
|
+
# for the HTML application. The layout can use any of the methods defined in SuperSettings::Application::Helper.
|
77
|
+
# @return [String]
|
78
|
+
def layout
|
79
|
+
"layout.html.erb"
|
80
|
+
end
|
81
|
+
|
82
|
+
# Subclasses can override this method to add custom HTML to the <head> element in the HTML application.
|
83
|
+
# This can be used to add additional script or meta tags needed for CSRF protection, etc.
|
84
|
+
# @param request [Rack::Request] current reqeust object
|
85
|
+
# @return [String]
|
86
|
+
def add_to_head(request)
|
87
|
+
end
|
88
|
+
|
89
|
+
# Subclasses can override this method to return the login URL for the application. If this is
|
90
|
+
# provided, then a user will be redirected to that URL if they are not authenticated when loading
|
91
|
+
# the HTML application.
|
92
|
+
# @param request [Rack::Request] current reqeust object
|
93
|
+
# @return [String]
|
94
|
+
def login_url(request)
|
95
|
+
end
|
96
|
+
|
97
|
+
private
|
98
|
+
|
99
|
+
def handle_request(env)
|
100
|
+
request = Rack::Request.new(env)
|
101
|
+
path = request.path[@path_prefix.length, request.path.length]
|
102
|
+
if request.get?
|
103
|
+
if path == "/" || path == ""
|
104
|
+
return handle_root_request(request)
|
105
|
+
elsif path == "/settings"
|
106
|
+
return handle_index_request(request)
|
107
|
+
elsif path == "/setting"
|
108
|
+
return handle_show_request(request)
|
109
|
+
elsif path == "/setting/history"
|
110
|
+
return handle_history_request(request)
|
111
|
+
elsif path == "/last_updated_at"
|
112
|
+
return handle_last_updated_at_request(request)
|
113
|
+
elsif path == "/updated_since"
|
114
|
+
return handle_updated_since_request(request)
|
115
|
+
end
|
116
|
+
elsif request.post?
|
117
|
+
if path == "/settings"
|
118
|
+
return handle_update_request(request)
|
119
|
+
end
|
120
|
+
end
|
121
|
+
@app.call(env)
|
122
|
+
end
|
123
|
+
|
124
|
+
def handle_root_request(request)
|
125
|
+
response = check_authorization(request, write_required: true) do |user|
|
126
|
+
[200, {"Content-Type" => "text/html; charset=utf-8"}, [Application.new(:default, add_to_head(request)).render("index.html.erb")]]
|
127
|
+
end
|
128
|
+
|
129
|
+
if [401, 403].include?(response.first)
|
130
|
+
location = login_url(request)
|
131
|
+
if location
|
132
|
+
response = [302, {"Location" => location}, []]
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
response
|
137
|
+
end
|
138
|
+
|
139
|
+
def handle_index_request(request)
|
140
|
+
check_authorization(request) do |user|
|
141
|
+
json_response(200, RestAPI.index)
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
def handle_show_request(request)
|
146
|
+
check_authorization(request) do |user|
|
147
|
+
setting = RestAPI.show(request.params["key"])
|
148
|
+
if setting
|
149
|
+
json_response(200, setting)
|
150
|
+
else
|
151
|
+
json_response(404, nil)
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
def handle_update_request(request)
|
157
|
+
check_authorization(request, write_required: true) do |user|
|
158
|
+
result = SuperSettings::RestAPI.update(post_params(request)["settings"], changed_by(user))
|
159
|
+
if result[:success]
|
160
|
+
json_response(200, result)
|
161
|
+
else
|
162
|
+
json_response(422, result)
|
163
|
+
end
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
def handle_history_request(request)
|
168
|
+
check_authorization(request) do |user|
|
169
|
+
history = RestAPI.history(request.params["key"], limit: request.params["limit"], offset: request.params["offset"])
|
170
|
+
if history
|
171
|
+
json_response(200, history)
|
172
|
+
else
|
173
|
+
json_response(404, nil)
|
174
|
+
end
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
def handle_last_updated_at_request(request)
|
179
|
+
check_authorization(request) do |user|
|
180
|
+
json_response(200, RestAPI.last_updated_at)
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
def handle_updated_since_request(request)
|
185
|
+
check_authorization(request) do |user|
|
186
|
+
json_response(200, RestAPI.updated_since(request.params["time"]))
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
def check_authorization(request, write_required: false)
|
191
|
+
user = current_user(request)
|
192
|
+
return json_response(401, error: "Authentiation required") unless authenticated?(user)
|
193
|
+
allowed = (write_required ? allow_write?(user) : allow_read?(user))
|
194
|
+
return json_response(403, error: "Access denied") unless allowed
|
195
|
+
yield(user)
|
196
|
+
end
|
197
|
+
|
198
|
+
def json_response(status, payload)
|
199
|
+
[status, RESPONSE_HEADERS, [payload.to_json]]
|
200
|
+
end
|
201
|
+
|
202
|
+
def post_params(request)
|
203
|
+
if request.content_type.to_s.match?(/\Aapplication\/json/i) && request.body
|
204
|
+
request.params.merge(JSON.parse(request.body.string))
|
205
|
+
else
|
206
|
+
request.params
|
207
|
+
end
|
208
|
+
end
|
209
|
+
end
|
210
|
+
end
|
@@ -0,0 +1,195 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "erb"
|
4
|
+
|
5
|
+
module SuperSettings
|
6
|
+
class RestAPI
|
7
|
+
class << self
|
8
|
+
# Get all settings sorted by key. This endpoint may be called with a REST GET request.
|
9
|
+
#
|
10
|
+
# `GET /`
|
11
|
+
#
|
12
|
+
# The response payload is:
|
13
|
+
# ```
|
14
|
+
# [
|
15
|
+
# {
|
16
|
+
# key: string,
|
17
|
+
# value: object,
|
18
|
+
# value_type: string,
|
19
|
+
# description string,
|
20
|
+
# created_at: iso8601 string,
|
21
|
+
# updated_at: iso8601 string
|
22
|
+
# },
|
23
|
+
# ...
|
24
|
+
# ]
|
25
|
+
# ```
|
26
|
+
def index
|
27
|
+
settings = Setting.active.sort_by(&:key)
|
28
|
+
{settings: settings.collect(&:as_json)}
|
29
|
+
end
|
30
|
+
|
31
|
+
# Get a setting by id.
|
32
|
+
#
|
33
|
+
# `GET /setting`
|
34
|
+
#
|
35
|
+
# Query parameters
|
36
|
+
#
|
37
|
+
# * key - setting key
|
38
|
+
#
|
39
|
+
# The response payload is:
|
40
|
+
# ```
|
41
|
+
# {
|
42
|
+
# key: string,
|
43
|
+
# value: object,
|
44
|
+
# value_type: string,
|
45
|
+
# description string,
|
46
|
+
# created_at: iso8601 string,
|
47
|
+
# updated_at: iso8601 string
|
48
|
+
# }
|
49
|
+
# ```
|
50
|
+
def show(key)
|
51
|
+
Setting.find_by_key(key)&.as_json
|
52
|
+
end
|
53
|
+
|
54
|
+
# The update operation uses a transaction to atomically update all settings.
|
55
|
+
#
|
56
|
+
# `POST /settings`
|
57
|
+
#
|
58
|
+
# The format of the parameters is an array of hashes with each setting identified by the key.
|
59
|
+
# The settings should include either `value` and `value_type` (and optionally `description`) to
|
60
|
+
# insert or update a setting, or `deleted` to delete the setting.
|
61
|
+
#
|
62
|
+
# ```
|
63
|
+
# { settings: [
|
64
|
+
# {
|
65
|
+
# key: string,
|
66
|
+
# value: object,
|
67
|
+
# value_type: string,
|
68
|
+
# description: string,
|
69
|
+
# },
|
70
|
+
# {
|
71
|
+
# key: string,
|
72
|
+
# deleted: boolean,
|
73
|
+
# },
|
74
|
+
# ...
|
75
|
+
# ]
|
76
|
+
# }
|
77
|
+
# ```
|
78
|
+
#
|
79
|
+
# The response will be either `{success: true}` or `{success: false, errors: {key => [string], ...}}`
|
80
|
+
def update(settings_params, changed_by = nil)
|
81
|
+
all_valid, settings = Setting.bulk_update(Array(settings_params), changed_by)
|
82
|
+
if all_valid
|
83
|
+
{success: true}
|
84
|
+
else
|
85
|
+
errors = {}
|
86
|
+
settings.each do |setting|
|
87
|
+
if setting.errors.any?
|
88
|
+
errors[setting.key] = setting.errors.values.flatten
|
89
|
+
end
|
90
|
+
end
|
91
|
+
{success: false, errors: errors}
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
# Return the history of the setting.
|
96
|
+
#
|
97
|
+
# `GET /setting/history`
|
98
|
+
#
|
99
|
+
# Query parameters
|
100
|
+
#
|
101
|
+
# * key - setting key
|
102
|
+
# * limit - number of history items to return
|
103
|
+
# * offset - index to start fetching items from (most recent items are first)
|
104
|
+
#
|
105
|
+
# The response format is:
|
106
|
+
# ```
|
107
|
+
# {
|
108
|
+
# key: string,
|
109
|
+
# encrypted: boolean,
|
110
|
+
# histories: [
|
111
|
+
# {
|
112
|
+
# value: object,
|
113
|
+
# changed_by: string,
|
114
|
+
# created_at: iso8601 string
|
115
|
+
# },
|
116
|
+
# ...
|
117
|
+
# ],
|
118
|
+
# previous_page_params: hash,
|
119
|
+
# next_page_params: hash
|
120
|
+
# }
|
121
|
+
# ```
|
122
|
+
def history(key, limit: nil, offset: 0)
|
123
|
+
setting = Setting.find_by_key(key)
|
124
|
+
return nil unless setting
|
125
|
+
|
126
|
+
offset = [offset.to_i, 0].max
|
127
|
+
limit = limit.to_i
|
128
|
+
fetch_limit = (limit > 0 ? limit + 1 : nil)
|
129
|
+
histories = setting.history(limit: fetch_limit, offset: offset)
|
130
|
+
|
131
|
+
payload = {key: setting.key, encrypted: setting.encrypted?}
|
132
|
+
|
133
|
+
if limit > 0 && !histories.empty?
|
134
|
+
if offset > 0
|
135
|
+
previous_page_params = {key: setting.key, offset: [offset - limit, 0].max, limit: limit}
|
136
|
+
end
|
137
|
+
if histories.size > limit
|
138
|
+
histories = histories.take(limit)
|
139
|
+
next_page_params = {key: setting.key, offset: offset + limit, limit: limit}
|
140
|
+
end
|
141
|
+
payload[:previous_page_params] = previous_page_params if previous_page_params
|
142
|
+
payload[:next_page_params] = next_page_params if next_page_params
|
143
|
+
end
|
144
|
+
|
145
|
+
payload[:histories] = histories.collect do |history|
|
146
|
+
history_values = {value: history.value, changed_by: history.changed_by_display, created_at: history.created_at}
|
147
|
+
history_values[:deleted] = true if history.deleted?
|
148
|
+
history_values
|
149
|
+
end
|
150
|
+
|
151
|
+
payload
|
152
|
+
end
|
153
|
+
|
154
|
+
# Return the timestamp of the most recently updated setting.
|
155
|
+
#
|
156
|
+
# `GET /last_updated_at`
|
157
|
+
# #
|
158
|
+
# The response payload is:
|
159
|
+
# {
|
160
|
+
# last_updated_at: iso8601 string
|
161
|
+
# }
|
162
|
+
def last_updated_at
|
163
|
+
{last_updated_at: Setting.last_updated_at.utc.iso8601}
|
164
|
+
end
|
165
|
+
|
166
|
+
# Return settings that have been updated since a specified timestamp.
|
167
|
+
#
|
168
|
+
# `GET /updated_since`
|
169
|
+
#
|
170
|
+
# Query parameters
|
171
|
+
#
|
172
|
+
# * time - iso8601 string
|
173
|
+
#
|
174
|
+
# The response payload is:
|
175
|
+
# ```
|
176
|
+
# [
|
177
|
+
# {
|
178
|
+
# key: string,
|
179
|
+
# value: object,
|
180
|
+
# value_type: string,
|
181
|
+
# description string,
|
182
|
+
# created_at: iso8601 string,
|
183
|
+
# updated_at: iso8601 string
|
184
|
+
# },
|
185
|
+
# ...
|
186
|
+
# ]
|
187
|
+
# ```
|
188
|
+
def updated_since(time)
|
189
|
+
time = Coerce.time(time)
|
190
|
+
settings = Setting.updated_since(time)
|
191
|
+
{settings: settings.collect(&:as_json)}
|
192
|
+
end
|
193
|
+
end
|
194
|
+
end
|
195
|
+
end
|