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.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +9 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +313 -0
  5. data/VERSION +1 -0
  6. data/app/helpers/super_settings/settings_helper.rb +32 -0
  7. data/app/views/layouts/super_settings/settings.html.erb +20 -0
  8. data/config/routes.rb +13 -0
  9. data/db/migrate/20210414004553_create_super_settings.rb +34 -0
  10. data/lib/super_settings/application/api.js +88 -0
  11. data/lib/super_settings/application/helper.rb +119 -0
  12. data/lib/super_settings/application/images/edit.svg +1 -0
  13. data/lib/super_settings/application/images/info.svg +1 -0
  14. data/lib/super_settings/application/images/plus.svg +1 -0
  15. data/lib/super_settings/application/images/slash.svg +1 -0
  16. data/lib/super_settings/application/images/trash.svg +1 -0
  17. data/lib/super_settings/application/index.html.erb +169 -0
  18. data/lib/super_settings/application/layout.html.erb +22 -0
  19. data/lib/super_settings/application/layout_styles.css +193 -0
  20. data/lib/super_settings/application/scripts.js +718 -0
  21. data/lib/super_settings/application/styles.css +122 -0
  22. data/lib/super_settings/application.rb +38 -0
  23. data/lib/super_settings/attributes.rb +24 -0
  24. data/lib/super_settings/coerce.rb +66 -0
  25. data/lib/super_settings/configuration.rb +144 -0
  26. data/lib/super_settings/controller_actions.rb +81 -0
  27. data/lib/super_settings/encryption.rb +76 -0
  28. data/lib/super_settings/engine.rb +70 -0
  29. data/lib/super_settings/history_item.rb +26 -0
  30. data/lib/super_settings/local_cache.rb +306 -0
  31. data/lib/super_settings/rack_middleware.rb +210 -0
  32. data/lib/super_settings/rest_api.rb +195 -0
  33. data/lib/super_settings/setting.rb +599 -0
  34. data/lib/super_settings/storage/active_record_storage.rb +123 -0
  35. data/lib/super_settings/storage/http_storage.rb +279 -0
  36. data/lib/super_settings/storage/redis_storage.rb +293 -0
  37. data/lib/super_settings/storage/test_storage.rb +158 -0
  38. data/lib/super_settings/storage.rb +254 -0
  39. data/lib/super_settings/version.rb +5 -0
  40. data/lib/super_settings.rb +213 -0
  41. data/lib/tasks/super_settings.rake +9 -0
  42. data/super_settings.gemspec +35 -0
  43. 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