super_settings 1.0.2 → 2.0.1

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