super_settings 1.0.2 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -0
  3. data/README.md +128 -26
  4. data/VERSION +1 -1
  5. data/app/helpers/super_settings/settings_helper.rb +13 -3
  6. data/app/views/layouts/super_settings/settings.html.erb +1 -1
  7. data/config/routes.rb +1 -1
  8. data/db/migrate/20210414004553_create_super_settings.rb +1 -7
  9. data/lib/super_settings/application/api.js +4 -1
  10. data/lib/super_settings/application/helper.rb +56 -17
  11. data/lib/super_settings/application/images/arrow-down-short.svg +3 -0
  12. data/lib/super_settings/application/images/arrow-up-short.svg +3 -0
  13. data/lib/super_settings/application/images/info-circle.svg +4 -0
  14. data/lib/super_settings/application/images/pencil-square.svg +4 -0
  15. data/lib/super_settings/application/images/plus.svg +3 -1
  16. data/lib/super_settings/application/images/trash3.svg +3 -0
  17. data/lib/super_settings/application/images/x-circle.svg +4 -0
  18. data/lib/super_settings/application/index.html.erb +54 -37
  19. data/lib/super_settings/application/layout.html.erb +5 -2
  20. data/lib/super_settings/application/layout_styles.css +7 -151
  21. data/lib/super_settings/application/layout_vars.css.erb +21 -0
  22. data/lib/super_settings/application/scripts.js +162 -37
  23. data/lib/super_settings/application/style_vars.css.erb +62 -0
  24. data/lib/super_settings/application/styles.css +183 -14
  25. data/lib/super_settings/application.rb +18 -11
  26. data/lib/super_settings/attributes.rb +1 -8
  27. data/lib/super_settings/configuration.rb +9 -0
  28. data/lib/super_settings/controller_actions.rb +2 -2
  29. data/lib/super_settings/engine.rb +1 -1
  30. data/lib/super_settings/history_item.rb +1 -1
  31. data/lib/super_settings/http_client.rb +165 -0
  32. data/lib/super_settings/rack_application.rb +3 -3
  33. data/lib/super_settings/rest_api.rb +5 -4
  34. data/lib/super_settings/setting.rb +13 -2
  35. data/lib/super_settings/storage/active_record_storage.rb +7 -0
  36. data/lib/super_settings/storage/history_attributes.rb +31 -0
  37. data/lib/super_settings/storage/http_storage.rb +60 -184
  38. data/lib/super_settings/storage/json_storage.rb +201 -0
  39. data/lib/super_settings/storage/mongodb_storage.rb +238 -0
  40. data/lib/super_settings/storage/redis_storage.rb +49 -111
  41. data/lib/super_settings/storage/s3_storage.rb +165 -0
  42. data/lib/super_settings/storage/storage_attributes.rb +64 -0
  43. data/lib/super_settings/storage/test_storage.rb +3 -5
  44. data/lib/super_settings/storage/transaction.rb +67 -0
  45. data/lib/super_settings/storage.rb +13 -6
  46. data/lib/super_settings/time_precision.rb +36 -0
  47. data/lib/super_settings.rb +11 -0
  48. data/super_settings.gemspec +4 -2
  49. metadata +22 -9
  50. data/lib/super_settings/application/images/edit.svg +0 -1
  51. data/lib/super_settings/application/images/info.svg +0 -1
  52. data/lib/super_settings/application/images/slash.svg +0 -1
  53. data/lib/super_settings/application/images/trash.svg +0 -1
@@ -10,31 +10,38 @@ module SuperSettings
10
10
  # @param layout [String, Symbol] path to an ERB template to use as the layout around the application UI. You can
11
11
  # pass the symbol +:default+ to use the default layout that ships with the gem.
12
12
  # @param add_to_head [String] HTML code to add to the <head> element on the page.
13
- def initialize(layout = nil, add_to_head = nil)
13
+ # @param api_base_url [String] the base URL for the REST API.
14
+ # @param color_scheme [Symbol] whether to use dark mode for the application UI. If +nil+, the user's system
15
+ # preference will be used.
16
+ def initialize(layout: nil, add_to_head: nil, api_base_url: nil, color_scheme: nil)
14
17
  if layout
15
18
  layout = File.expand_path(File.join("application", "layout.html.erb"), __dir__) if layout == :default
16
- @layout = ERB.new(File.read(layout))
19
+ @layout = ERB.new(File.read(layout)) if layout
17
20
  @add_to_head = add_to_head
21
+ else
22
+ @layout = nil
23
+ @add_to_head = nil
18
24
  end
25
+
26
+ @api_base_url = api_base_url
27
+ @color_scheme = color_scheme&.to_sym
19
28
  end
20
29
 
21
- # Render the specified ERB file in the lib/application directory distributed with the gem.
30
+ # Render the web UI application HTML.
22
31
  #
23
32
  # @return [void]
24
- def render(erb_file)
25
- template = ERB.new(File.read(File.expand_path(File.join("application", erb_file), __dir__)))
33
+ def render
34
+ template = ERB.new(File.read(File.expand_path(File.join("application", "index.html.erb"), __dir__)))
26
35
  html = template.result(binding)
27
- if @layout
28
- render_layout { html }
29
- else
30
- html
31
- end
36
+ html = render_layout { html } if @layout
37
+ html = html.html_safe if html.respond_to?(:html_safe)
38
+ html
32
39
  end
33
40
 
34
41
  private
35
42
 
36
43
  def render_layout
37
- @layout.result(binding)
44
+ @layout&.result(binding)
38
45
  end
39
46
  end
40
47
  end
@@ -4,20 +4,13 @@ module SuperSettings
4
4
  # Interface to expose mass setting attributes on an object. Setting attributes with a
5
5
  # hash will simply call the attribute writers for each key in the hash.
6
6
  module Attributes
7
- class UnknownAttributeError < StandardError
8
- end
9
-
10
7
  def initialize(attributes = nil)
11
8
  self.attributes = attributes if attributes
12
9
  end
13
10
 
14
11
  def attributes=(values)
15
12
  values.each do |name, value|
16
- if respond_to?(:"#{name}=", true)
17
- send(:"#{name}=", value)
18
- else
19
- raise UnknownAttributeError.new("unknown attribute #{name.to_s.inspect} for #{self.class}")
20
- end
13
+ send(:"#{name}=", value) if respond_to?(:"#{name}=", true)
21
14
  end
22
15
  end
23
16
  end
@@ -25,6 +25,7 @@ module SuperSettings
25
25
  def initialize
26
26
  @superclass = nil
27
27
  @web_ui_enabled = true
28
+ @color_scheme = false
28
29
  @changed_by_block = nil
29
30
  end
30
31
 
@@ -63,6 +64,14 @@ module SuperSettings
63
64
  !!@web_ui_enabled
64
65
  end
65
66
 
67
+ # Set dark mode for the web UI. Possible values are :light, :dark, or :system.
68
+ # The default value is :light.
69
+ attr_writer :color_scheme
70
+
71
+ def color_scheme
72
+ (@color_scheme ||= :light).to_sym
73
+ end
74
+
66
75
  # Enhance the controller. You can define methods or call controller class methods like
67
76
  # +before_action+, etc. in the block. These will be applied to the engine controller.
68
77
  # This is essentially the same a monkeypatching the controller class.
@@ -19,7 +19,7 @@ module SuperSettings
19
19
 
20
20
  # Render the HTML application for managing settings.
21
21
  def root
22
- html = SuperSettings::Application.new.render("index.html.erb")
22
+ html = SuperSettings::Application.new.render
23
23
  render html: html.html_safe, layout: true
24
24
  end
25
25
 
@@ -40,7 +40,7 @@ module SuperSettings
40
40
 
41
41
  # API endpoint for updating settings. See SuperSettings::RestAPI for details.
42
42
  def update
43
- changed_by = Configuration.instance.controller.changed_by(self)
43
+ changed_by = SuperSettings.configuration.controller.changed_by(self)
44
44
  result = SuperSettings::RestAPI.update(params[:settings], changed_by)
45
45
  if result[:success]
46
46
  render json: result
@@ -27,7 +27,7 @@ module SuperSettings
27
27
 
28
28
  config.after_initialize do
29
29
  # Call the deferred initialization block.
30
- configuration = Configuration.instance
30
+ configuration = SuperSettings.configuration
31
31
  configuration.call
32
32
 
33
33
  SuperSettings.refresh_interval = configuration.refresh_interval unless configuration.refresh_interval.nil?
@@ -27,7 +27,7 @@ module SuperSettings
27
27
  def changed_by_display
28
28
  return changed_by if changed_by.nil?
29
29
 
30
- display_proc = Configuration.instance.model.changed_by_display
30
+ display_proc = SuperSettings.configuration.model.changed_by_display
31
31
  if display_proc && !changed_by.nil?
32
32
  display_proc.call(changed_by) || changed_by
33
33
  else
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module SuperSettings
7
+ # This is a simple HTTP client that is used to communicate with the REST API. It
8
+ # will keep the connection alive and reuse it on subsequent requests.
9
+ class HttpClient
10
+ DEFAULT_HEADERS = {"Accept" => "application/json"}.freeze
11
+ DEFAULT_TIMEOUT = 5.0
12
+ KEEP_ALIVE_TIMEOUT = 60
13
+
14
+ class Error < StandardError
15
+ end
16
+
17
+ class NotFoundError < Error
18
+ end
19
+
20
+ class InvalidRecordError < Error
21
+ attr_reader :errors
22
+
23
+ def initialize(message, errors:)
24
+ super(message)
25
+ @errors = errors
26
+ end
27
+ end
28
+
29
+ def initialize(base_url, headers: nil, params: nil, timeout: nil, user: nil, password: nil)
30
+ base_url = "#{base_url}/" unless base_url.end_with?("/")
31
+ @base_uri = URI(base_url)
32
+ @base_uri.query = query_string(params) if params
33
+ @headers = headers ? DEFAULT_HEADERS.merge(headers) : DEFAULT_HEADERS
34
+ @timeout = timeout || DEFAULT_TIMEOUT
35
+ @user = user
36
+ @password = password
37
+ @mutex = Mutex.new
38
+ @connections = []
39
+ end
40
+
41
+ def get(path, params = nil)
42
+ request = Net::HTTP::Get.new(request_uri(path, params))
43
+ send_request(request)
44
+ end
45
+
46
+ def post(path, params = nil)
47
+ request = Net::HTTP::Post.new(request_uri(path))
48
+ request.body = JSON.dump(params) if params
49
+ send_request(request)
50
+ end
51
+
52
+ private
53
+
54
+ def send_request(request)
55
+ set_headers(request)
56
+ response_payload = nil
57
+ attempts = 0
58
+
59
+ with_connection do |http|
60
+ http.start unless http.started?
61
+ response = http.request(request)
62
+
63
+ begin
64
+ response.value # raises exception unless response is a success
65
+ response_payload = JSON.parse(response.body)
66
+ rescue Net::ProtocolError
67
+ if [404, 410].include?(response.code.to_i)
68
+ raise NotFoundError.new("#{response.code} #{response.message}")
69
+ elsif response.code.to_i == 422
70
+ raise InvalidRecordError.new("#{response.code} #{response.message}", errors: JSON.parse(response.body)["errors"])
71
+ else
72
+ raise Error.new("#{response.code} #{response.message}")
73
+ end
74
+ rescue JSON::JSONError => e
75
+ raise Error.new(e.message)
76
+ end
77
+ rescue IOError, Errno::ECONNRESET => connection_error
78
+ attempts += 1
79
+ retry if attempts <= 1
80
+ raise connection_error
81
+ end
82
+
83
+ response_payload
84
+ end
85
+
86
+ def with_connection(&block)
87
+ http = pop_connection
88
+ begin
89
+ response = yield(http)
90
+ return_connection(http)
91
+ response
92
+ rescue => e
93
+ begin
94
+ http.finish if http.started?
95
+ rescue IOError
96
+ end
97
+ raise e
98
+ end
99
+ end
100
+
101
+ def pop_connection
102
+ http = nil
103
+ @mutex.synchronize do
104
+ http = @connections.pop
105
+ end
106
+ http = nil unless http&.started?
107
+ http ||= new_connection
108
+ http
109
+ end
110
+
111
+ def return_connection(http)
112
+ @mutex.synchronize do
113
+ if @connections.empty?
114
+ @connections.push(http)
115
+ http = nil
116
+ end
117
+ end
118
+
119
+ if http
120
+ begin
121
+ http.finish if http.started?
122
+ rescue IOError
123
+ end
124
+ end
125
+ end
126
+
127
+ def new_connection
128
+ http = Net::HTTP.new(@base_uri.host, @base_uri.port || @base_uri.inferred_port)
129
+ http.use_ssl = @base_uri.scheme == "https"
130
+ http.open_timeout = @timeout
131
+ http.read_timeout = @timeout
132
+ http.write_timeout = @timeout
133
+ http.keep_alive_timeout = KEEP_ALIVE_TIMEOUT
134
+ http
135
+ end
136
+
137
+ def set_headers(request)
138
+ @headers.each do |name, value|
139
+ name = name.to_s
140
+ values = Array(value)
141
+ request[name] = values[0].to_s
142
+ values[1, values.length].each do |val|
143
+ request.add_field(name, val.to_s)
144
+ end
145
+ end
146
+ end
147
+
148
+ def request_uri(path, params = nil)
149
+ uri = URI.join(@base_uri, path.delete_prefix("/"))
150
+ if (params && !params.empty?) || (@base_uri.query && !@base_uri.query.empty?)
151
+ uri.query = [uri.query, query_string(params)].join("&")
152
+ end
153
+ uri
154
+ end
155
+
156
+ def query_string(params)
157
+ q = []
158
+ q << @base_uri.query unless @base_uri.query.to_s.empty?
159
+ params&.each do |name, value|
160
+ q << "#{URI.encode_www_form_component(name.to_s)}=#{URI.encode_www_form_component(value.to_s)}"
161
+ end
162
+ q.join("&")
163
+ end
164
+ end
165
+ end
@@ -122,7 +122,7 @@ module SuperSettings
122
122
  #
123
123
  # @return [Boolean]
124
124
  def web_ui_enabled?
125
- true
125
+ SuperSettings.configuration.controller.web_ui_enabled?
126
126
  end
127
127
 
128
128
  private
@@ -159,7 +159,7 @@ module SuperSettings
159
159
 
160
160
  def handle_root_request(request)
161
161
  response = check_authorization(request, write_required: true) do |user|
162
- [200, {"content-type" => "text/html; charset=utf-8", "cache-control" => "no-cache"}, [Application.new(:default, add_to_head(request)).render("index.html.erb")]]
162
+ [200, {"content-type" => "text/html; charset=utf-8", "cache-control" => "no-cache"}, [Application.new(layout: :default, add_to_head: add_to_head(request), color_scheme: SuperSettings.configuration.controller.color_scheme).render]]
163
163
  end
164
164
 
165
165
  if [401, 403].include?(response.first)
@@ -236,7 +236,7 @@ module SuperSettings
236
236
 
237
237
  def post_params(request)
238
238
  if request.content_type.to_s.match?(/\Aapplication\/json/i) && request.body
239
- request.params.merge(JSON.parse(request.body.string))
239
+ request.params.merge(JSON.parse(request.body.read))
240
240
  else
241
241
  request.params
242
242
  end
@@ -23,7 +23,7 @@ module SuperSettings
23
23
  # ...
24
24
  # ]
25
25
  def index
26
- settings = Setting.active.sort_by(&:key)
26
+ settings = Setting.active.reject(&:deleted?).sort_by(&:key)
27
27
  {settings: settings.collect(&:as_json)}
28
28
  end
29
29
 
@@ -46,7 +46,8 @@ module SuperSettings
46
46
  # updated_at: iso8601 string
47
47
  # }
48
48
  def show(key)
49
- Setting.find_by_key(key)&.as_json
49
+ setting = Setting.find_by_key(key)
50
+ setting.as_json if setting && !setting.deleted?
50
51
  end
51
52
 
52
53
  # The update operation uses a transaction to atomically update all settings.
@@ -156,7 +157,7 @@ module SuperSettings
156
157
  #
157
158
  # @example
158
159
  # GET /last_updated_at
159
- # #
160
+ #
160
161
  # The response payload is:
161
162
  # {
162
163
  # last_updated_at: iso8601 string
@@ -188,7 +189,7 @@ module SuperSettings
188
189
  # ]
189
190
  def updated_since(time)
190
191
  time = Coerce.time(time)
191
- settings = Setting.updated_since(time)
192
+ settings = Setting.updated_since(time).reject(&:deleted?)
192
193
  {settings: settings.collect(&:as_json)}
193
194
  end
194
195
  end
@@ -109,6 +109,7 @@ module SuperSettings
109
109
  # @param time [Time]
110
110
  # @return [Array<Setting>]
111
111
  def updated_since(time)
112
+ time = SuperSettings::Coerce.time(time)
112
113
  storage.with_connection do
113
114
  storage.updated_since(time).collect { |record| new(record) }
114
115
  end
@@ -219,6 +220,16 @@ module SuperSettings
219
220
  next if Coerce.blank?(setting_params["key"])
220
221
  next if ["value_type", "value", "description", "deleted"].all? { |name| Coerce.blank?(setting_params[name]) }
221
222
 
223
+ key_was = setting_params["key_was"]
224
+ if key_was && !changed.include?(key_was)
225
+ old_setting = Setting.find_by_key(key_was)
226
+ if old_setting
227
+ old_setting.deleted = true
228
+ old_setting.changed_by = changed_by
229
+ changed[old_setting.key] = old_setting
230
+ end
231
+ end
232
+
222
233
  key = setting_params["key"]
223
234
  setting = changed[key] || Setting.find_by_key(key)
224
235
  unless setting
@@ -372,7 +383,7 @@ module SuperSettings
372
383
  #
373
384
  # @param val [Time, DateTime]
374
385
  def created_at=(val)
375
- val = Coerce.time(val)
386
+ val = TimePrecision.new(val).time
376
387
  will_change!(:created_at, val) unless created_at == val
377
388
  @record.created_at = val
378
389
  end
@@ -388,7 +399,7 @@ module SuperSettings
388
399
  #
389
400
  # @param val [Time, DateTime]
390
401
  def updated_at=(val)
391
- val = Coerce.time(val)
402
+ val = TimePrecision.new(val).time
392
403
  will_change!(:updated_at, val) unless updated_at == val
393
404
  @record.updated_at = val
394
405
  end
@@ -60,6 +60,13 @@ module SuperSettings
60
60
  Model.transaction(&block)
61
61
  end
62
62
 
63
+ def destroy_all
64
+ ApplicationRecord.transaction do
65
+ Model.delete_all
66
+ HistoryModel.delete_all
67
+ end
68
+ end
69
+
63
70
  protected
64
71
 
65
72
  # Only load settings asynchronously if there is an extra database connection left in the
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperSettings
4
+ module Storage
5
+ # Generic class that can be extended to represent a history record for a setting in memory.
6
+ class HistoryAttributes
7
+ include SuperSettings::Attributes
8
+
9
+ attr_accessor :key, :value, :changed_by
10
+ attr_writer :deleted
11
+ attr_reader :created_at
12
+
13
+ def initialize(*)
14
+ @key = nil
15
+ @value = nil
16
+ @changed_by = nil
17
+ @created_at = nil
18
+ @deleted = false
19
+ super
20
+ end
21
+
22
+ def created_at=(val)
23
+ @created_at = TimePrecision.new(val).time
24
+ end
25
+
26
+ def deleted?
27
+ !!@deleted
28
+ end
29
+ end
30
+ end
31
+ end