super_settings 0.0.0.rc1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|