active_delivery 1.0.0.rc2 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveDelivery
4
+ module TestDelivery
5
+ class << self
6
+ def enable
7
+ raise ArgumentError, "block is required" unless block_given?
8
+ begin
9
+ clear
10
+ Thread.current.thread_variable_set(:active_delivery_testing, true)
11
+ yield
12
+ ensure
13
+ Thread.current.thread_variable_set(:active_delivery_testing, false)
14
+ end
15
+ end
16
+
17
+ def enabled?
18
+ Thread.current.thread_variable_get(:active_delivery_testing) == true
19
+ end
20
+
21
+ def track(delivery, options)
22
+ store << [delivery, options]
23
+ end
24
+
25
+ def track_line(line)
26
+ lines << line
27
+ end
28
+
29
+ def store
30
+ Thread.current.thread_variable_get(:active_delivery_testing_store) || Thread.current.thread_variable_set(:active_delivery_testing_store, [])
31
+ end
32
+
33
+ def lines
34
+ Thread.current.thread_variable_get(:active_delivery_testing_lines) || Thread.current.thread_variable_set(:active_delivery_testing_lines, [])
35
+ end
36
+
37
+ def clear
38
+ store.clear
39
+ lines.clear
40
+ end
41
+ end
42
+
43
+ def perform_notify(delivery, **options)
44
+ return super unless test?
45
+ TestDelivery.track(delivery, options)
46
+ nil
47
+ end
48
+
49
+ def notify_line(line, *__rest__, &__block__)
50
+ res = super
51
+ TestDelivery.track_line(line) if res
52
+ end; respond_to?(:ruby2_keywords, true) && (ruby2_keywords :notify_line)
53
+
54
+ def test?
55
+ TestDelivery.enabled?
56
+ end
57
+ end
58
+ end
59
+
60
+ ActiveDelivery::Base.prepend ActiveDelivery::TestDelivery
61
+
62
+ require "active_delivery/testing/rspec" if defined?(RSpec::Core)
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AbstractNotifier
4
+ # NotificationDelivery payload wrapper which contains
5
+ # information about the current notifier class
6
+ # and knows how to trigger the delivery
7
+ class NotificationDelivery
8
+ attr_reader :action_name
9
+
10
+ def initialize(owner_class, action_name, params: {}, args: [], kwargs: {})
11
+ @owner_class = owner_class
12
+ @action_name = action_name
13
+ @params = params
14
+ @args = args
15
+ @kwargs = kwargs
16
+ end
17
+
18
+ def processed
19
+ return @processed if instance_variable_defined?(:@processed)
20
+
21
+ @processed = notifier.process_action(action_name, *args, **kwargs) || Notification.new(nil)
22
+ end
23
+
24
+ alias_method :notification, :processed
25
+
26
+ def notify_later
27
+ owner_class.async_adapter.enqueue(owner_class.name, action_name, params: params, args: args, kwargs: kwargs)
28
+ end
29
+
30
+ def notify_now
31
+ return unless notification.payload
32
+
33
+ notifier.deliver!(notification)
34
+ end
35
+
36
+ private
37
+
38
+ attr_reader :owner_class, :params, :args, :kwargs
39
+
40
+ def notifier
41
+ @notifier ||= owner_class.new(action_name, **params)
42
+ end
43
+ end
44
+
45
+ # Notification object contains the compiled payload to be delivered
46
+ class Notification
47
+ attr_reader :payload
48
+
49
+ def initialize(payload)
50
+ @payload = payload
51
+ end
52
+ end
53
+
54
+ # Base class for notifiers
55
+ class Base
56
+ class ParamsProxy
57
+ attr_reader :notifier_class, :params
58
+
59
+ def initialize(notifier_class, params)
60
+ @notifier_class = notifier_class
61
+ @params = params
62
+ end
63
+
64
+ # rubocop:disable Style/MethodMissingSuper
65
+ def method_missing(method_name, *args, **kwargs)
66
+ NotificationDelivery.new(notifier_class, method_name, params: params, args: args, kwargs: kwargs)
67
+ end
68
+ # rubocop:enable Style/MethodMissingSuper
69
+
70
+ def respond_to_missing?(*args)
71
+ notifier_class.respond_to_missing?(*args)
72
+ end
73
+ end
74
+
75
+ class << self
76
+ attr_writer :driver
77
+
78
+ def driver
79
+ return @driver if instance_variable_defined?(:@driver)
80
+
81
+ @driver =
82
+ if superclass.respond_to?(:driver)
83
+ superclass.driver
84
+ else
85
+ raise "Driver not found for #{name}. " \
86
+ "Please, specify driver via `self.driver = MyDriver`"
87
+ end
88
+ end
89
+
90
+ def async_adapter=(args)
91
+ adapter, options = Array(args)
92
+ @async_adapter = AsyncAdapters.lookup(adapter, options)
93
+ end
94
+
95
+ def async_adapter
96
+ return @async_adapter if instance_variable_defined?(:@async_adapter)
97
+
98
+ @async_adapter =
99
+ if superclass.respond_to?(:async_adapter)
100
+ superclass.async_adapter
101
+ else
102
+ AbstractNotifier.async_adapter
103
+ end
104
+ end
105
+
106
+ def default(method_name = nil, **hargs, &block)
107
+ return @defaults_generator = block if block
108
+
109
+ return @defaults_generator = proc { send(method_name) } unless method_name.nil?
110
+
111
+ @default_params =
112
+ if superclass.respond_to?(:default_params)
113
+ superclass.default_params.merge(hargs).freeze
114
+ else
115
+ hargs.freeze
116
+ end
117
+ end
118
+
119
+ def defaults_generator
120
+ return @defaults_generator if instance_variable_defined?(:@defaults_generator)
121
+
122
+ @defaults_generator =
123
+ if superclass.respond_to?(:defaults_generator)
124
+ superclass.defaults_generator
125
+ end
126
+ end
127
+
128
+ def default_params
129
+ return @default_params if instance_variable_defined?(:@default_params)
130
+
131
+ @default_params =
132
+ if superclass.respond_to?(:default_params)
133
+ superclass.default_params.dup
134
+ else
135
+ {}
136
+ end
137
+ end
138
+
139
+ def method_missing(method_name, *args, **kwargs)
140
+ if action_methods.include?(method_name.to_s)
141
+ NotificationDelivery.new(self, method_name, args: args, kwargs: kwargs)
142
+ else
143
+ super
144
+ end
145
+ end
146
+
147
+ def with(params)
148
+ ParamsProxy.new(self, params)
149
+ end
150
+
151
+ def respond_to_missing?(method_name, _include_private = false)
152
+ action_methods.include?(method_name.to_s) || super
153
+ end
154
+
155
+ # See https://github.com/rails/rails/blob/b13a5cb83ea00d6a3d71320fd276ca21049c2544/actionpack/lib/abstract_controller/base.rb#L74
156
+ def action_methods
157
+ @action_methods ||= begin
158
+ # All public instance methods of this class, including ancestors
159
+ methods = (public_instance_methods(true) -
160
+ # Except for public instance methods of Base and its ancestors
161
+ Base.public_instance_methods(true) +
162
+ # Be sure to include shadowed public instance methods of this class
163
+ public_instance_methods(false))
164
+
165
+ methods.map!(&:to_s)
166
+
167
+ methods.to_set
168
+ end
169
+ end
170
+ end
171
+
172
+ attr_reader :params, :notification_name
173
+
174
+ def initialize(notification_name, **params)
175
+ @notification_name = notification_name
176
+ @params = params.freeze
177
+ end
178
+
179
+ def process_action(...)
180
+ public_send(...)
181
+ end; respond_to?(:ruby2_keywords, true) && (ruby2_keywords :process_action)
182
+
183
+ def deliver!(notification)
184
+ self.class.driver.call(notification.payload)
185
+ end
186
+
187
+ def notification(**payload)
188
+ merge_defaults!(payload)
189
+
190
+ payload[:body] = implicit_payload_body unless payload.key?(:body)
191
+
192
+ raise ArgumentError, "Notification body must be present" if
193
+ payload[:body].nil? || payload[:body].empty?
194
+
195
+ @notification = Notification.new(payload)
196
+ end
197
+
198
+ private
199
+
200
+ def implicit_payload_body
201
+ # no-op — override to provide custom logic
202
+ end
203
+
204
+ def merge_defaults!(payload)
205
+ defaults =
206
+ if self.class.defaults_generator
207
+ instance_exec(&self.class.defaults_generator)
208
+ else
209
+ self.class.default_params
210
+ end
211
+
212
+ defaults.each do |k, v|
213
+ payload[k] = v unless payload.key?(k)
214
+ end
215
+ end
216
+ end
217
+ end
@@ -1,6 +1,38 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveDelivery
4
+ class Delivery # :nodoc:
5
+ attr_reader :params, :options, :metadata, :notification, :owner
6
+
7
+ def initialize(owner, notification:, params:, options:, metadata:)
8
+ @owner = owner
9
+ @notification = notification
10
+ @params = params.freeze
11
+ @options = options.freeze
12
+ @metadata = metadata.freeze
13
+ end
14
+
15
+ def deliver_later = owner.perform_notify(self)
16
+
17
+ def deliver_now = owner.perform_notify(self, sync: true)
18
+
19
+ def delivery_class = owner.class
20
+ end
21
+
22
+ class << self
23
+ # Whether to memoize resolved handler classes or not.
24
+ # Set to false if you're using a code reloader (e.g., Zeitwerk).
25
+ #
26
+ # Defaults to true (i.e. memoization is enabled
27
+ attr_accessor :cache_classes
28
+ # Whether to enforce specifying available delivery actions via .delivers in the
29
+ # delivery classes
30
+ attr_accessor :deliver_actions_required
31
+ end
32
+
33
+ self.cache_classes = true
34
+ self.deliver_actions_required = false
35
+
4
36
  # Base class for deliveries.
5
37
  #
6
38
  # Delivery object describes how to notify a user about
@@ -10,6 +42,8 @@ module ActiveDelivery
10
42
  # (i.e. mailers, notifiers). That means that calling a method on delivery class invokes the
11
43
  # same method on the corresponding class, e.g.:
12
44
  #
45
+ # EventsDelivery.one_hour_before(profile, event).deliver_later
46
+ # # or
13
47
  # EventsDelivery.notify(:one_hour_before, profile, event)
14
48
  #
15
49
  # # under the hood it calls
@@ -20,14 +54,14 @@ module ActiveDelivery
20
54
  #
21
55
  # Delivery also supports _parameterized_ calling:
22
56
  #
23
- # EventsDelivery.with(profile: profile).notify(:canceled, event)
57
+ # EventsDelivery.with(profile: profile).canceled(event).deliver_later
24
58
  #
25
59
  # The parameters could be accessed through `params` instance method (e.g.
26
60
  # to implement guard-like logic).
27
61
  #
28
62
  # When params are presents the parametrized mailer is used, i.e.:
29
63
  #
30
- # EventsMailer.with(profile: profile).canceled(event)
64
+ # EventsMailer.with(profile: profile).canceled(event).deliver_later
31
65
  #
32
66
  # See https://api.rubyonrails.org/classes/ActionMailer/Parameterized.html
33
67
  class Base
@@ -47,6 +81,8 @@ module ActiveDelivery
47
81
  notify(mid, *args, **hargs, sync: true)
48
82
  end
49
83
 
84
+ alias_method :notify_now, :notify!
85
+
50
86
  def delivery_lines
51
87
  @lines ||= if superclass.respond_to?(:delivery_lines)
52
88
  superclass.delivery_lines.each_with_object({}) do |(key, val), acc|
@@ -57,12 +93,19 @@ module ActiveDelivery
57
93
  end
58
94
  end
59
95
 
60
- def register_line(line_id, line_class, **options)
96
+ def register_line(line_id, line_class = nil, notifier: nil, **options)
97
+ raise ArgumentError, "A line class or notifier configuration must be provided" if line_class.nil? && notifier.nil?
98
+
99
+ # Configure Notifier
100
+ if line_class.nil?
101
+ line_class = ActiveDelivery::Lines::Notifier
102
+ end
103
+
61
104
  delivery_lines[line_id] = line_class.new(id: line_id, owner: self, **options)
62
105
 
63
106
  instance_eval <<~CODE, __FILE__, __LINE__ + 1
64
107
  def #{line_id}(val)
65
- delivery_lines[:#{line_id}].handler_class = val
108
+ delivery_lines[:#{line_id}].handler_class_name = val
66
109
  end
67
110
 
68
111
  def #{line_id}_class
@@ -81,8 +124,46 @@ module ActiveDelivery
81
124
  end
82
125
 
83
126
  def abstract_class? = abstract_class == true
127
+
128
+ # Specify explicitly which actions are supported by the delivery.
129
+ def delivers(*actions)
130
+ actions.each do |mid|
131
+ class_eval <<~CODE, __FILE__, __LINE__ + 1
132
+ def self.#{mid}(...)
133
+ new.#{mid}(...)
134
+ end
135
+
136
+ def #{mid}(*args, **kwargs)
137
+ delivery(
138
+ notification: :#{mid},
139
+ params: args,
140
+ options: kwargs
141
+ )
142
+ end
143
+ CODE
144
+ end
145
+ end
146
+
147
+ def respond_to_missing?(mid, include_private = false)
148
+ unless ActiveDelivery.deliver_actions_required
149
+ return true if delivery_lines.any? { |_, line| line.notify?(mid) }
150
+ end
151
+
152
+ super
153
+ end
154
+
155
+ def method_missing(mid, *args, **kwargs)
156
+ return super unless respond_to_missing?(mid)
157
+
158
+ # Lazily define a class method to avoid lookups
159
+ delivers(mid)
160
+
161
+ public_send(mid, *args, **kwargs)
162
+ end
84
163
  end
85
164
 
165
+ self.abstract_class = true
166
+
86
167
  attr_reader :params, :notification_name
87
168
 
88
169
  def initialize(**params)
@@ -91,30 +172,73 @@ module ActiveDelivery
91
172
  end
92
173
 
93
174
  # Enqueues delivery (i.e. uses #deliver_later for mailers)
94
- def notify(mid, ...)
95
- @notification_name = mid
96
- do_notify(...)
175
+ def notify(mid, *args, **kwargs)
176
+ perform_notify(
177
+ delivery(notification: mid, params: args, options: kwargs)
178
+ )
97
179
  end
98
180
 
99
181
  # The same as .notify but delivers synchronously
100
182
  # (i.e. #deliver_now for mailers)
101
- def notify!(mid, *args, **hargs)
102
- notify(mid, *args, **hargs, sync: true)
183
+ def notify!(mid, *args, **kwargs)
184
+ perform_notify(
185
+ delivery(notification: mid, params: args, options: kwargs),
186
+ sync: true
187
+ )
103
188
  end
104
189
 
105
- private
190
+ alias_method :notify_now, :notify!
191
+
192
+ def respond_to_missing?(mid, include_private = false)
193
+ unless ActiveDelivery.deliver_actions_required
194
+ return true if delivery_lines.any? { |_, line| line.notify?(mid) }
195
+ end
196
+
197
+ super
198
+ end
106
199
 
107
- def do_notify(*args, sync: false, **kwargs)
200
+ def method_missing(mid, *args, **kwargs)
201
+ return super unless respond_to_missing?(mid)
202
+
203
+ # Lazily define a method to avoid future lookups
204
+ self.class.class_eval <<~CODE, __FILE__, __LINE__ + 1
205
+ def #{mid}(*args, **kwargs)
206
+ delivery(
207
+ notification: :#{mid},
208
+ params: args,
209
+ options: kwargs
210
+ )
211
+ end
212
+ CODE
213
+
214
+ public_send(mid, *args, **kwargs)
215
+ end
216
+
217
+ protected
218
+
219
+ def perform_notify(delivery, sync: false)
108
220
  delivery_lines.each do |type, line|
109
- next if line.handler_class.nil?
110
- next unless line.notify?(notification_name)
221
+ next unless line.notify?(delivery.notification)
111
222
 
112
- notify_line(type, *args, params: params, sync: sync, **kwargs)
223
+ notify_line(type, line, delivery, sync: sync)
113
224
  end
114
225
  end
115
226
 
116
- def notify_line(type, ...)
117
- delivery_lines[type].notify(notification_name, ...)
227
+ private
228
+
229
+ def notify_line(type, line, delivery, sync:)
230
+ line.notify(
231
+ delivery.notification,
232
+ *delivery.params,
233
+ params: params,
234
+ sync: sync,
235
+ **delivery.options
236
+ )
237
+ true
238
+ end
239
+
240
+ def delivery(notification:, params: nil, options: nil, metadata: nil)
241
+ Delivery.new(self, notification: notification, params: params, options: options, metadata: metadata)
118
242
  end
119
243
 
120
244
  def delivery_lines
@@ -1,17 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ unless "".respond_to?(:safe_constantize)
4
+ require "active_delivery/ext/string_constantize"
5
+ using ActiveDelivery::Ext::StringConstantize
6
+ end
7
+
3
8
  module ActiveDelivery
4
9
  module Lines
5
10
  class Base
6
11
  attr_reader :id, :options
7
12
  attr_accessor :owner
8
- attr_writer :handler_class
13
+ attr_accessor :handler_class_name
9
14
 
10
15
  def initialize(id:, owner:, **options)
11
16
  @id = id
12
17
  @owner = owner
13
18
  @options = options.tap(&:freeze)
14
- @resolver = options[:resolver]
19
+ @resolver = options[:resolver] || build_pattern_resolver(options[:resolver_pattern])
15
20
  end
16
21
 
17
22
  def dup_for(new_owner)
@@ -23,7 +28,7 @@ module ActiveDelivery
23
28
  end
24
29
 
25
30
  def notify?(method_name)
26
- handler_class.respond_to?(method_name)
31
+ handler_class&.respond_to?(method_name)
27
32
  end
28
33
 
29
34
  def notify_now(handler, mid, ...)
@@ -38,26 +43,47 @@ module ActiveDelivery
38
43
  end
39
44
 
40
45
  def handler_class
41
- return @handler_class if instance_variable_defined?(:@handler_class)
46
+ if ::ActiveDelivery.cache_classes
47
+ return @handler_class if instance_variable_defined?(:@handler_class)
48
+ end
42
49
 
43
50
  return @handler_class = nil if owner.abstract_class?
44
51
 
45
- @handler_class = resolve_class(owner.name) ||
46
- superclass_handler
52
+ superline = owner.superclass.delivery_lines[id] if owner.superclass.respond_to?(:delivery_lines) && owner.superclass.delivery_lines[id]
53
+
54
+ # If an explicit class name has been specified somewhere in the ancestor chain, use it.
55
+ class_name = @handler_class_name || superline&.handler_class_name
56
+
57
+ @handler_class =
58
+ if class_name
59
+ class_name.is_a?(Class) ? class_name : class_name.safe_constantize
60
+ else
61
+ resolve_class(owner) || superline&.handler_class
62
+ end
47
63
  end
48
64
 
49
65
  private
50
66
 
51
- def superclass_handler
52
- handler_method = "#{id}_class"
67
+ attr_reader :resolver
53
68
 
54
- return if owner.superclass == ActiveDelivery::Base
55
- return unless owner.superclass.respond_to?(handler_method)
69
+ def build_pattern_resolver(pattern)
70
+ return unless pattern
56
71
 
57
- owner.superclass.public_send(handler_method)
58
- end
72
+ proc do |delivery|
73
+ delivery_class = delivery.name
59
74
 
60
- attr_reader :resolver
75
+ next unless delivery_class
76
+
77
+ *namespace, delivery_name = delivery_class.split("::")
78
+
79
+ delivery_namespace = ""
80
+ delivery_namespace = "#{namespace.join("::")}::" unless namespace.empty?
81
+
82
+ delivery_name = delivery_name.sub(/Delivery$/, "")
83
+
84
+ (pattern % {delivery_class: delivery_class, delivery_name: delivery_name, delivery_namespace: delivery_namespace}).safe_constantize
85
+ end
86
+ end
61
87
  end
62
88
  end
63
89
  end
@@ -4,8 +4,8 @@ module AbstractNotifier
4
4
  module AsyncAdapters
5
5
  class ActiveJob
6
6
  class DeliveryJob < ::ActiveJob::Base
7
- def perform(notifier_class, payload)
8
- AbstractNotifier::Notification.new(notifier_class.constantize, payload).notify_now
7
+ def perform(notifier_class, ...)
8
+ AbstractNotifier::NotificationDelivery.new(notifier_class.constantize, ...).notify_now
9
9
  end
10
10
  end
11
11
 
@@ -17,8 +17,8 @@ module AbstractNotifier
17
17
  @job = job.set(queue: queue)
18
18
  end
19
19
 
20
- def enqueue(notifier_class, payload)
21
- job.perform_later(notifier_class.name, payload)
20
+ def enqueue(...)
21
+ job.perform_later(...)
22
22
  end
23
23
  end
24
24
  end