rage-rb 1.7.0 → 1.8.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 61d3c256494b63668809f556d221c9dd86b1542ce30051c689e1f30a7b1aca00
4
- data.tar.gz: 268fc8606e772b06985effe6918270f2ecb1c21b191bfa59ae9a74c0eeb8f459
3
+ metadata.gz: 4adc4048bfa84558a6b86b15cbcab8e6153858878425bde66db78a975fa46f46
4
+ data.tar.gz: 46bef9f42fdd681bdf593f037a73ca8ae2d435acad9eed5ddf4b3740bf61b601
5
5
  SHA512:
6
- metadata.gz: 0136a9c93cb94dc161367ecac78b3d106404e4450fb1f4c6df8d6e38400da242fdaeebf54a7ed18ae5fc0037bea9e62ae94b5870883a2adb493e5721059a9eb8
7
- data.tar.gz: c10509dbc8ae25ae0f1c19b35903664863fb07fb81409fd6cc16b962bde35d46f7bb552562994757b74fcc4d32f6ef9970a66f0893d0c36f11da5c27a7f2b4df
6
+ metadata.gz: 754b954d46d065020e0c2184c04f1a13d0c0a64ddf11d1b034062df4cc669460c259032a8bf40a61c876579f1774e50e9b57181d4d3e1301c4610577e11c6734
7
+ data.tar.gz: 4207556922f8fd2c82d07ba8664f53847dc1e5afb6bb5d38260ac0ccd428dd011fd8c6bf34f69aa75af97044a838df23c6c36b5baae1ae7cd3d4c08455fc699b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.8.0] - 2024-08-06
4
+
5
+ ### Added
6
+
7
+ - Support WebSockets (#88).
8
+
3
9
  ## [1.7.0] - 2024-07-30
4
10
 
5
11
  ### Added
data/Gemfile CHANGED
@@ -18,4 +18,5 @@ group :test do
18
18
  gem "connection_pool", "~> 2.0"
19
19
  gem "rbnacl"
20
20
  gem "domain_name"
21
+ gem "websocket-client-simple"
21
22
  end
data/OVERVIEW.md CHANGED
@@ -31,7 +31,7 @@ class UsersController < RageController::API
31
31
  end
32
32
  ```
33
33
 
34
- Before processing requests to `UsersController#show`, Rage has to [register](https://github.com/rage-rb/rage/blob/master/lib/rage/controller/api.rb#L10) the show action. Registering means defining a new method that will look like this:
34
+ Before processing requests to `UsersController#show`, Rage has to [register](https://github.com/rage-rb/rage/blob/master/lib/rage/controller/api.rb#L11) the show action. Registering means defining a new method that will look like this:
35
35
 
36
36
  ```ruby
37
37
  class UsersController
data/README.md CHANGED
@@ -44,7 +44,7 @@ Start coding!
44
44
 
45
45
  ## Getting Started
46
46
 
47
- This gem is designed to be a drop-in replacement for Rails in API mode. Public API is mostly expected to match Rails, however, sometimes it's a little bit more strict.
47
+ This gem is designed to be a drop-in replacement for Rails in API mode. Public API is expected to fully match Rails.
48
48
 
49
49
  Check out in-depth API docs for more information:
50
50
 
@@ -60,6 +60,8 @@ Also, see the following integration guides:
60
60
  - [Rails integration](https://github.com/rage-rb/rage/wiki/Rails-integration)
61
61
  - [RSpec integration](https://github.com/rage-rb/rage/wiki/RSpec-integration)
62
62
 
63
+ If you are a first-time contributor, make sure to check the [overview doc](https://github.com/rage-rb/rage/blob/master/OVERVIEW.md) that shows how Rage's core components interact with each other.
64
+
63
65
  ### Example
64
66
 
65
67
  A sample controller could look like this:
@@ -157,7 +159,7 @@ class BenchmarksController < ApplicationController
157
159
  end
158
160
  ```
159
161
 
160
- ![Requests per second-2](https://github.com/user-attachments/assets/b7ee0bff-e7c8-4fd4-a565-ce0b67a6320e)
162
+ ![Requests per second](https://github.com/user-attachments/assets/04678788-0034-4db4-9582-d0bc16fd9e28)
161
163
 
162
164
  ## Upcoming releases
163
165
 
data/lib/rage/all.rb CHANGED
@@ -26,6 +26,7 @@ require_relative "logger/text_formatter"
26
26
  require_relative "logger/json_formatter"
27
27
  require_relative "logger/logger"
28
28
 
29
+ require_relative "middleware/origin_validator"
29
30
  require_relative "middleware/fiber_wrapper"
30
31
  require_relative "middleware/cors"
31
32
  require_relative "middleware/reloader"
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rage::Cable
4
+ # Create a new Cable application.
5
+ #
6
+ # @example
7
+ # map "/cable" do
8
+ # run Rage.cable.application
9
+ # end
10
+ def self.application
11
+ protocol = Rage.config.cable.protocol
12
+ protocol.init(__router)
13
+
14
+ handler = __build_handler(protocol)
15
+ accept_response = [0, protocol.protocol_definition, []]
16
+
17
+ application = ->(env) do
18
+ if env["rack.upgrade?"] == :websocket
19
+ env["rack.upgrade"] = handler
20
+ accept_response
21
+ else
22
+ [426, { "Connection" => "Upgrade", "Upgrade" => "websocket" }, []]
23
+ end
24
+ end
25
+
26
+ Rage.with_middlewares(application, Rage.config.cable.middlewares)
27
+ end
28
+
29
+ # @private
30
+ def self.__router
31
+ @__router ||= Router.new
32
+ end
33
+
34
+ # @private
35
+ def self.__build_handler(protocol)
36
+ klass = Class.new do
37
+ def initialize(protocol)
38
+ Iodine.on_state(:on_start) do
39
+ unless Fiber.scheduler
40
+ Fiber.set_scheduler(Rage::FiberScheduler.new)
41
+ end
42
+ end
43
+
44
+ @protocol = protocol
45
+ end
46
+
47
+ def on_open(connection)
48
+ Fiber.schedule do
49
+ @protocol.on_open(connection)
50
+ rescue => e
51
+ log_error(e)
52
+ end
53
+ end
54
+
55
+ def on_message(connection, data)
56
+ Fiber.schedule do
57
+ @protocol.on_message(connection, data)
58
+ rescue => e
59
+ log_error(e)
60
+ end
61
+ end
62
+
63
+ if protocol.respond_to?(:on_close)
64
+ def on_close(connection)
65
+ return unless ::Iodine.running?
66
+
67
+ Fiber.schedule do
68
+ @protocol.on_close(connection)
69
+ rescue => e
70
+ log_error(e)
71
+ end
72
+ end
73
+ end
74
+
75
+ if protocol.respond_to?(:on_shutdown)
76
+ def on_shutdown(connection)
77
+ @protocol.on_shutdown(connection)
78
+ rescue => e
79
+ log_error(e)
80
+ end
81
+ end
82
+
83
+ private
84
+
85
+ def log_error(e)
86
+ Rage.logger.error("Unhandled exception has occured - #{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}")
87
+ end
88
+ end
89
+
90
+ klass.new(protocol)
91
+ end
92
+
93
+ # Broadcast data directly to a named stream.
94
+ #
95
+ # @param stream [String] the name of the stream
96
+ # @param data [Object] the object to send to the clients. This will later be encoded according to the protocol used.
97
+ # @example
98
+ # Rage.cable.broadcast("chat", { message: "A new member has joined!" })
99
+ def self.broadcast(stream, data)
100
+ Rage.config.cable.protocol.broadcast(stream, data)
101
+ end
102
+
103
+ # @!parse [ruby]
104
+ # # @abstract
105
+ # class WebSocketConnection
106
+ # # Write data to the connection.
107
+ # #
108
+ # # @param data [String] the data to write
109
+ # def write(data)
110
+ # end
111
+ #
112
+ # # Subscribe to a channel.
113
+ # #
114
+ # # @param name [String] the channel name
115
+ # def subscribe(name)
116
+ # end
117
+ #
118
+ # # Close the connection.
119
+ # def close
120
+ # end
121
+ # end
122
+
123
+ module Protocol
124
+ end
125
+ end
126
+
127
+ require_relative "protocol/actioncable_v1_json"
128
+ require_relative "channel"
129
+ require_relative "connection"
130
+ require_relative "router"
@@ -0,0 +1,452 @@
1
+ require "set"
2
+
3
+ class Rage::Cable::Channel
4
+ # @private
5
+ INTERNAL_ACTIONS = [:subscribed, :unsubscribed]
6
+
7
+ class << self
8
+ # @private
9
+ attr_reader :__prepared_actions
10
+
11
+ # @private
12
+ attr_reader :__channels
13
+
14
+ # @private
15
+ # returns a list of actions that can be called remotely
16
+ def __register_actions
17
+ actions = (
18
+ public_instance_methods(true) - Rage::Cable::Channel.public_instance_methods(true)
19
+ ).reject { |m| m.start_with?("__rage_tmp") || m.start_with?("__run") }
20
+
21
+ @__prepared_actions = (INTERNAL_ACTIONS + actions).each_with_object({}) do |action_name, memo|
22
+ memo[action_name] = __register_action_proc(action_name)
23
+ end
24
+
25
+ actions - INTERNAL_ACTIONS
26
+ end
27
+
28
+ # @private
29
+ # rubocop:disable Layout/HeredocIndentation, Layout/IndentationWidth, Layout/EndAlignment, Layout/ElseAlignment
30
+ def __register_action_proc(action_name)
31
+ if action_name == :subscribed && @__hooks
32
+ before_subscribe_chunk = if @__hooks[:before_subscribe]
33
+ lines = @__hooks[:before_subscribe].map do |h|
34
+ condition = if h[:if] && h[:unless]
35
+ "if #{h[:if]} && !#{h[:unless]}"
36
+ elsif h[:if]
37
+ "if #{h[:if]}"
38
+ elsif h[:unless]
39
+ "unless #{h[:unless]}"
40
+ end
41
+
42
+ <<~RUBY
43
+ #{h[:name]} #{condition}
44
+ return if @__subscription_rejected
45
+ RUBY
46
+ end
47
+
48
+ lines.join("\n")
49
+ end
50
+
51
+ after_subscribe_chunk = if @__hooks[:after_subscribe]
52
+ lines = @__hooks[:after_subscribe].map do |h|
53
+ condition = if h[:if] && h[:unless]
54
+ "if #{h[:if]} && !#{h[:unless]}"
55
+ elsif h[:if]
56
+ "if #{h[:if]}"
57
+ elsif h[:unless]
58
+ "unless #{h[:unless]}"
59
+ end
60
+
61
+ <<~RUBY
62
+ #{h[:name]} #{condition}
63
+ RUBY
64
+ end
65
+
66
+ lines.join("\n")
67
+ end
68
+ end
69
+
70
+ if action_name == :unsubscribed && @__hooks
71
+ before_unsubscribe_chunk = if @__hooks[:before_unsubscribe]
72
+ lines = @__hooks[:before_unsubscribe].map do |h|
73
+ condition = if h[:if] && h[:unless]
74
+ "if #{h[:if]} && !#{h[:unless]}"
75
+ elsif h[:if]
76
+ "if #{h[:if]}"
77
+ elsif h[:unless]
78
+ "unless #{h[:unless]}"
79
+ end
80
+
81
+ <<~RUBY
82
+ #{h[:name]} #{condition}
83
+ RUBY
84
+ end
85
+
86
+ lines.join("\n")
87
+ end
88
+
89
+ after_unsubscribe_chunk = if @__hooks[:after_unsubscribe]
90
+ lines = @__hooks[:after_unsubscribe].map do |h|
91
+ condition = if h[:if] && h[:unless]
92
+ "if #{h[:if]} && !#{h[:unless]}"
93
+ elsif h[:if]
94
+ "if #{h[:if]}"
95
+ elsif h[:unless]
96
+ "unless #{h[:unless]}"
97
+ end
98
+
99
+ <<~RUBY
100
+ #{h[:name]} #{condition}
101
+ RUBY
102
+ end
103
+
104
+ lines.join("\n")
105
+ end
106
+ end
107
+
108
+ rescue_handlers_chunk = if @__rescue_handlers
109
+ lines = @__rescue_handlers.map do |klasses, handler|
110
+ <<~RUBY
111
+ rescue #{klasses.join(", ")} => __e
112
+ #{instance_method(handler).arity == 0 ? handler : "#{handler}(__e)"}
113
+ RUBY
114
+ end
115
+
116
+ lines.join("\n")
117
+ else
118
+ ""
119
+ end
120
+
121
+ periodic_timers_chunk = if @__periodic_timers
122
+ set_up_periodic_timers
123
+
124
+ if action_name == :subscribed
125
+ <<~RUBY
126
+ self.class.__channels << self unless subscription_rejected?
127
+ RUBY
128
+ elsif action_name == :unsubscribed
129
+ <<~RUBY
130
+ self.class.__channels.delete(self)
131
+ RUBY
132
+ end
133
+ else
134
+ ""
135
+ end
136
+
137
+ is_subscribing = action_name == :subscribed
138
+ activerecord_loaded = defined?(::ActiveRecord)
139
+
140
+ method_name = class_eval <<~RUBY, __FILE__, __LINE__ + 1
141
+ def __run_#{action_name}(data)
142
+ #{if is_subscribing
143
+ <<~RUBY
144
+ @__is_subscribing = true
145
+ RUBY
146
+ end}
147
+
148
+ #{before_subscribe_chunk}
149
+ #{before_unsubscribe_chunk}
150
+
151
+ #{if instance_method(action_name).arity == 0
152
+ <<~RUBY
153
+ #{action_name}
154
+ RUBY
155
+ else
156
+ <<~RUBY
157
+ #{action_name}(data)
158
+ RUBY
159
+ end}
160
+
161
+ #{after_subscribe_chunk}
162
+ #{after_unsubscribe_chunk}
163
+ #{periodic_timers_chunk}
164
+ #{rescue_handlers_chunk}
165
+
166
+ #{if activerecord_loaded
167
+ <<~RUBY
168
+ ensure
169
+ if ActiveRecord::Base.connection_pool.active_connection?
170
+ ActiveRecord::Base.connection_handler.clear_active_connections!
171
+ end
172
+ RUBY
173
+ end}
174
+ end
175
+ RUBY
176
+
177
+ eval("->(channel, data) { channel.#{method_name}(data) }")
178
+ end
179
+ # rubocop:enable all
180
+
181
+ # @private
182
+ def __prepare_id_method(method_name)
183
+ define_method(method_name) do
184
+ @__identified_by[method_name]
185
+ end
186
+ end
187
+
188
+ # Register a new `before_subscribe` hook that will be called before the {subscribed} method.
189
+ #
190
+ # @example
191
+ # before_subscribe :my_method
192
+ # @example
193
+ # before_subscribe do
194
+ # ...
195
+ # end
196
+ # @example
197
+ # before_subscribe :my_method, if: -> { ... }
198
+ def before_subscribe(action_name = nil, **opts, &block)
199
+ add_action(:before_subscribe, action_name, **opts, &block)
200
+ end
201
+
202
+ # Register a new `after_subscribe` hook that will be called after the {subscribed} method.
203
+ #
204
+ # @example
205
+ # after_subscribe do
206
+ # ...
207
+ # end
208
+ # @example
209
+ # after_subscribe :my_method, unless: :subscription_rejected?
210
+ # @note This callback will be triggered even if the subscription was rejected with the {reject} method.
211
+ def after_subscribe(action_name = nil, **opts, &block)
212
+ add_action(:after_subscribe, action_name, **opts, &block)
213
+ end
214
+
215
+ # Register a new `before_unsubscribe` hook that will be called before the {unsubscribed} method.
216
+ def before_unsubscribe(action_name = nil, **opts, &block)
217
+ add_action(:before_unsubscribe, action_name, **opts, &block)
218
+ end
219
+
220
+ # Register a new `after_unsubscribe` hook that will be called after the {unsubscribed} method.
221
+ def after_unsubscribe(action_name = nil, **opts, &block)
222
+ add_action(:after_unsubscribe, action_name, **opts, &block)
223
+ end
224
+
225
+ # Register an exception handler.
226
+ #
227
+ # @param klasses [Class, Array<Class>] exception classes to watch on
228
+ # @param with [Symbol] the name of a handler method. The method can take one argument, which is the raised exception. Alternatively, you can pass a block, which can also take one argument.
229
+ # @example
230
+ # rescue_from StandardError, with: :report_error
231
+ #
232
+ # private
233
+ #
234
+ # def report_error(e)
235
+ # SomeExternalBugtrackingService.notify(e)
236
+ # end
237
+ # @example
238
+ # rescue_from StandardError do |e|
239
+ # SomeExternalBugtrackingService.notify(e)
240
+ # end
241
+ def rescue_from(*klasses, with: nil, &block)
242
+ unless with
243
+ if block_given?
244
+ with = define_tmp_method(block)
245
+ else
246
+ raise ArgumentError, "No handler provided. Pass the `with` keyword argument or provide a block."
247
+ end
248
+ end
249
+
250
+ if @__rescue_handlers.nil?
251
+ @__rescue_handlers = []
252
+ elsif @__rescue_handlers.frozen?
253
+ @__rescue_handlers = @__rescue_handlers.dup
254
+ end
255
+
256
+ @__rescue_handlers.unshift([klasses, with])
257
+ end
258
+
259
+ # Set up a timer to periodically perform a task on the channel. Accepts a method name or a block.
260
+ #
261
+ # @param method_name [Symbol, nil] the name of the method to call
262
+ # @param every [Integer] the calling period in seconds
263
+ # @example
264
+ # periodically every: 3.minutes do
265
+ # transmit({ action: :update_count, count: current_count })
266
+ # end
267
+ # @example
268
+ # periodically :update_count, every: 3.minutes
269
+ def periodically(method_name = nil, every:, &block)
270
+ callback_name = if block_given?
271
+ raise ArgumentError, "Pass the `method_name` argument or provide a block, not both" if method_name
272
+ define_tmp_method(block)
273
+ elsif method_name.is_a?(Symbol)
274
+ define_tmp_method(eval("-> { #{method_name} }"))
275
+ else
276
+ raise ArgumentError, "Expected a Symbol method name, got #{method_name.inspect}"
277
+ end
278
+
279
+ unless every.is_a?(Numeric) && every > 0
280
+ raise ArgumentError, "Expected every: to be a positive number of seconds, got #{every.inspect}"
281
+ end
282
+
283
+ callback = eval("->(channel) { channel.#{callback_name} }")
284
+
285
+ if @__periodic_timers.nil?
286
+ @__periodic_timers = []
287
+ elsif @__periodic_timers.frozen?
288
+ @__periodic_timers = @__periodic_timers.dup
289
+ end
290
+
291
+ @__periodic_timers << [callback, every]
292
+ end
293
+
294
+ protected
295
+
296
+ def set_up_periodic_timers
297
+ return if @__periodic_timers_set_up
298
+
299
+ @__channels = Set.new
300
+
301
+ @__periodic_timers.each do |callback, every|
302
+ ::Iodine.run_every((every * 1000).to_i) do
303
+ slice_length = (@__channels.length / 20.0).ceil
304
+
305
+ if slice_length != 0
306
+ @__channels.each_slice(slice_length) do |slice|
307
+ Fiber.schedule do
308
+ slice.each { |channel| callback.call(channel) }
309
+ rescue => e
310
+ Rage.logger.error("Unhandled exception has occured - #{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}")
311
+ end
312
+ end
313
+ end
314
+ end
315
+ end
316
+
317
+ @__periodic_timers_set_up = true
318
+ end
319
+
320
+ def add_action(action_type, action_name = nil, **opts, &block)
321
+ if block_given?
322
+ action_name = define_tmp_method(block)
323
+ elsif action_name.nil?
324
+ raise ArgumentError, "No handler provided. Pass the `action_name` parameter or provide a block."
325
+ end
326
+
327
+ _if, _unless = opts.values_at(:if, :unless)
328
+
329
+ action = {
330
+ name: action_name,
331
+ if: _if,
332
+ unless: _unless
333
+ }
334
+
335
+ action[:if] = define_tmp_method(action[:if]) if action[:if].is_a?(Proc)
336
+ action[:unless] = define_tmp_method(action[:unless]) if action[:unless].is_a?(Proc)
337
+
338
+ if @__hooks.nil?
339
+ @__hooks = {}
340
+ elsif @__hooks[action_type] && @__hooks.frozen?
341
+ @__hooks = @__hooks.dup
342
+ @__hooks[action_type] = @__hooks[action_type].dup
343
+ end
344
+
345
+ if @__hooks[action_type].nil?
346
+ @__hooks[action_type] = [action]
347
+ elsif (i = @__hooks[action_type].find_index { |a| a[:name] == action_name })
348
+ @__hooks[action_type][i] = action
349
+ else
350
+ @__hooks[action_type] << action
351
+ end
352
+ end
353
+
354
+ attr_writer :__hooks, :__rescue_handlers, :__periodic_timers
355
+
356
+ def inherited(klass)
357
+ klass.__hooks = @__hooks.freeze
358
+ klass.__rescue_handlers = @__rescue_handlers.freeze
359
+ klass.__periodic_timers = @__periodic_timers.freeze
360
+ end
361
+
362
+ @@__tmp_name_seed = ("a".."i").to_a.permutation
363
+
364
+ def define_tmp_method(block)
365
+ name = @@__tmp_name_seed.next.join
366
+ define_method("__rage_tmp_#{name}", block)
367
+ end
368
+ end # class << self
369
+
370
+ # @private
371
+ def __has_action?(action_name)
372
+ !INTERNAL_ACTIONS.include?(action_name) && self.class.__prepared_actions.has_key?(action_name)
373
+ end
374
+
375
+ # @private
376
+ def __run_action(action_name, data = nil)
377
+ self.class.__prepared_actions[action_name].call(self, data)
378
+ end
379
+
380
+ # @private
381
+ def initialize(connection, params, identified_by)
382
+ @__connection = connection
383
+ @__params = params
384
+ @__identified_by = identified_by
385
+ end
386
+
387
+ # Get the params hash passed in during the subscription process.
388
+ #
389
+ # @return [Hash{Symbol=>String,Array,Hash,Numeric,NilClass,TrueClass,FalseClass}]
390
+ def params
391
+ @__params
392
+ end
393
+
394
+ # Reject the subscription request. The method should only be called during the subscription
395
+ # process (i.e. inside the {subscribed} method or {before_subscribe}/{after_subscribe} hooks).
396
+ def reject
397
+ @__subscription_rejected = true
398
+ end
399
+
400
+ # Checks whether the {reject} method has been called.
401
+ #
402
+ # @return [Boolean]
403
+ def subscription_rejected?
404
+ !!@__subscription_rejected
405
+ end
406
+
407
+ # Subscribe to a stream.
408
+ #
409
+ # @param stream [String] the name of the stream
410
+ def stream_from(stream)
411
+ Rage.config.cable.protocol.subscribe(@__connection, stream, @__params)
412
+ end
413
+
414
+ # Broadcast data to all the clients subscribed to a stream.
415
+ #
416
+ # @param stream [String] the name of the stream
417
+ # @param data [Object] the data to send to the clients
418
+ # @example
419
+ # def subscribed
420
+ # broadcast("notifications", { message: "A new member has joined!" })
421
+ # end
422
+ def broadcast(stream, data)
423
+ Rage.config.cable.protocol.broadcast(stream, data)
424
+ end
425
+
426
+ # Transmit data to the current client.
427
+ #
428
+ # @param data [Object] the data to send to the client
429
+ # @example
430
+ # def subscribed
431
+ # transmit({ message: "Hello!" })
432
+ # end
433
+ def transmit(data)
434
+ message = Rage.config.cable.protocol.serialize(@__params, data)
435
+
436
+ if @__is_subscribing
437
+ # we expect a confirmation message to be sent as a result of a successful subscribe call;
438
+ # this will make sure `transmit` calls send data after the confirmation;
439
+ ::Iodine.defer { @__connection.write(message) }
440
+ else
441
+ @__connection.write(message)
442
+ end
443
+ end
444
+
445
+ # Called once a client has become a subscriber of the channel.
446
+ def subscribed
447
+ end
448
+
449
+ # Called once a client unsubscribes from the channel.
450
+ def unsubscribed
451
+ end
452
+ end