active_delivery 1.0.0.rc2 → 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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(**opts) ; owner.perform_notify(self, enqueue_options: opts); end
16
+
17
+ def deliver_now(**opts) ; owner.perform_notify(self, sync: true); end
18
+
19
+ def delivery_class ; owner.class; end
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; end
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,31 +172,75 @@ module ActiveDelivery
91
172
  end
92
173
 
93
174
  # Enqueues delivery (i.e. uses #deliver_later for mailers)
94
- def notify(mid, *__rest__, &__block__)
95
- @notification_name = mid
96
- do_notify(*__rest__, &__block__)
97
- end; respond_to?(:ruby2_keywords, true) && (ruby2_keywords :notify)
175
+ def notify(mid, *args, **kwargs)
176
+ perform_notify(
177
+ delivery(notification: mid, params: args, options: kwargs)
178
+ )
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, enqueue_options: {})
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, enqueue_options: enqueue_options)
113
224
  end
114
225
  end
115
226
 
116
- def notify_line(type, *__rest__, &__block__)
117
- delivery_lines[type].notify(notification_name, *__rest__, &__block__)
118
- end; respond_to?(:ruby2_keywords, true) && (ruby2_keywords :notify_line)
227
+ private
228
+
229
+ def notify_line(type, line, delivery, sync:, enqueue_options:)
230
+ line.notify(
231
+ delivery.notification,
232
+ *delivery.params,
233
+ params: params,
234
+ sync: sync,
235
+ enqueue_options: enqueue_options,
236
+ **delivery.options
237
+ )
238
+ true
239
+ end
240
+
241
+ def delivery(notification:, params: nil, options: nil, metadata: nil)
242
+ Delivery.new(self, notification: notification, params: params, options: options, metadata: metadata)
243
+ end
119
244
 
120
245
  def delivery_lines
121
246
  self.class.delivery_lines
@@ -37,9 +37,11 @@ module ActiveDelivery
37
37
  end
38
38
 
39
39
  module InstanceExt
40
- def do_notify(...)
41
- run_callbacks(:notify) { super(...) }
42
- end; respond_to?(:ruby2_keywords, true) && (ruby2_keywords :do_notify)
40
+ def perform_notify(delivery, *__rest__, &__block__)
41
+ # We need to store the notification name to be able to use it in callbacks if/unless
42
+ @notification_name = delivery.notification
43
+ run_callbacks(:notify) { super(delivery, *__rest__, &__block__) }
44
+ end; respond_to?(:ruby2_keywords, true) && (ruby2_keywords :perform_notify)
43
45
 
44
46
  def notify_line(kind, *__rest__, &__block__)
45
47
  run_callbacks(kind) { super(kind, *__rest__, &__block__) }
@@ -73,22 +75,24 @@ module ActiveDelivery
73
75
  skip_after_callbacks_if_terminated: true
74
76
  end
75
77
 
76
- def before_notify(method_or_block = nil, on: :notify, **options, &block)
77
- method_or_block ||= block
78
- _normalize_callback_options(options)
79
- set_callback on, :before, method_or_block, options
80
- end
78
+ %i[before after around].each do |kind|
79
+ define_method "#{kind}_notify" do |*names, on: :notify, **options, &block|
80
+ _normalize_callback_options(options)
81
81
 
82
- def after_notify(method_or_block = nil, on: :notify, **options, &block)
83
- method_or_block ||= block
84
- _normalize_callback_options(options)
85
- set_callback on, :after, method_or_block, options
86
- end
82
+ names.each do |name|
83
+ set_callback on, kind, name, options
84
+ end
85
+
86
+ set_callback on, kind, block, options if block
87
+ end
88
+
89
+ define_method "skip_#{kind}_notify" do |*names, on: :notify, **options|
90
+ _normalize_callback_options(options)
87
91
 
88
- def around_notify(method_or_block = nil, on: :notify, **options, &block)
89
- method_or_block ||= block
90
- _normalize_callback_options(options)
91
- set_callback on, :around, method_or_block, options
92
+ names.each do |name|
93
+ skip_callback(on, kind, name, options)
94
+ end
95
+ end
92
96
  end
93
97
  end
94
98
  end
@@ -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, *__rest__, &__block__)
@@ -32,32 +37,65 @@ module ActiveDelivery
32
37
  def notify_later(handler, mid, *__rest__, &__block__)
33
38
  end; respond_to?(:ruby2_keywords, true) && (ruby2_keywords :notify_later)
34
39
 
35
- def notify(mid, *args, params:, sync:, **kwargs)
40
+ def notify_later_with_options(handler, enqueue_options, mid, *__rest__, &__block__)
41
+ notify_later(handler, mid, *__rest__, &__block__)
42
+ end; respond_to?(:ruby2_keywords, true) && (ruby2_keywords :notify_later_with_options)
43
+
44
+ def notify(mid, *args, params:, sync:, enqueue_options:, **kwargs)
36
45
  clazz = params.empty? ? handler_class : handler_class.with(**params)
37
- sync ? notify_now(clazz, mid, *args, **kwargs) : notify_later(clazz, mid, *args, **kwargs)
46
+ if sync
47
+ return notify_now(clazz, mid, *args, **kwargs)
48
+ end
49
+
50
+ if enqueue_options.empty?
51
+ notify_later(clazz, mid, *args, **kwargs)
52
+ else
53
+ notify_later_with_options(clazz, enqueue_options, mid, *args, **kwargs)
54
+ end
38
55
  end
39
56
 
40
57
  def handler_class
41
- return @handler_class if instance_variable_defined?(:@handler_class)
58
+ if ::ActiveDelivery.cache_classes
59
+ return @handler_class if instance_variable_defined?(:@handler_class)
60
+ end
42
61
 
43
62
  return @handler_class = nil if owner.abstract_class?
44
63
 
45
- @handler_class = resolve_class(owner.name) ||
46
- superclass_handler
64
+ superline = owner.superclass.delivery_lines[id] if owner.superclass.respond_to?(:delivery_lines) && owner.superclass.delivery_lines[id]
65
+
66
+ # If an explicit class name has been specified somewhere in the ancestor chain, use it.
67
+ class_name = @handler_class_name || superline&.handler_class_name
68
+
69
+ @handler_class =
70
+ if class_name
71
+ class_name.is_a?(Class) ? class_name : class_name.safe_constantize
72
+ else
73
+ resolve_class(owner) || superline&.handler_class
74
+ end
47
75
  end
48
76
 
49
77
  private
50
78
 
51
- def superclass_handler
52
- handler_method = "#{id}_class"
79
+ attr_reader :resolver
53
80
 
54
- return if owner.superclass == ActiveDelivery::Base
55
- return unless owner.superclass.respond_to?(handler_method)
81
+ def build_pattern_resolver(pattern)
82
+ return unless pattern
56
83
 
57
- owner.superclass.public_send(handler_method)
58
- end
84
+ proc do |delivery|
85
+ delivery_class = delivery.name
59
86
 
60
- attr_reader :resolver
87
+ next unless delivery_class
88
+
89
+ *namespace, delivery_name = delivery_class.split("::")
90
+
91
+ delivery_namespace = ""
92
+ delivery_namespace = "#{namespace.join("::")}::" unless namespace.empty?
93
+
94
+ delivery_name = delivery_name.sub(/Delivery$/, "")
95
+
96
+ (pattern % {delivery_class: delivery_class, delivery_name: delivery_name, delivery_namespace: delivery_namespace}).safe_constantize
97
+ end
98
+ end
61
99
  end
62
100
  end
63
101
  end
@@ -5,9 +5,10 @@ module ActiveDelivery
5
5
  class Mailer < Base
6
6
  alias_method :mailer_class, :handler_class
7
7
 
8
- DEFAULT_RESOLVER = ->(name) { name.gsub(/Delivery$/, "Mailer").safe_constantize }
8
+ DEFAULT_RESOLVER = ->(klass) { klass.name&.gsub(/Delivery$/, "Mailer")&.safe_constantize }
9
9
 
10
10
  def notify?(method_name)
11
+ return unless mailer_class
11
12
  mailer_class.action_methods.include?(method_name.to_s)
12
13
  end
13
14
 
@@ -18,6 +19,10 @@ module ActiveDelivery
18
19
  def notify_later(mailer, mid, *__rest__, &__block__)
19
20
  mailer.public_send(mid, *__rest__, &__block__).deliver_later
20
21
  end; respond_to?(:ruby2_keywords, true) && (ruby2_keywords :notify_later)
22
+
23
+ def notify_later_with_options(mailer, enqueue_options, mid, *__rest__, &__block__)
24
+ mailer.public_send(mid, *__rest__, &__block__).deliver_later(**enqueue_options)
25
+ end; respond_to?(:ruby2_keywords, true) && (ruby2_keywords :notify_later_with_options)
21
26
  end
22
27
 
23
28
  ActiveDelivery::Base.register_line :mailer, Mailer, resolver: Mailer::DEFAULT_RESOLVER
@@ -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,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AbstractNotifier
4
+ module AsyncAdapters
5
+ class ActiveJob
6
+ class DeliveryJob < ::ActiveJob::Base
7
+ def perform(notifier_class, ...)
8
+ AbstractNotifier::NotificationDelivery.new(notifier_class.constantize, ...).notify_now
9
+ end
10
+ end
11
+
12
+ DEFAULT_QUEUE = "notifiers"
13
+
14
+ attr_reader :job, :queue
15
+
16
+ def initialize(queue: DEFAULT_QUEUE, job: DeliveryJob)
17
+ @job = job
18
+ @queue = queue
19
+ end
20
+
21
+ def enqueue(...)
22
+ job.set(queue: queue).perform_later(...)
23
+ end
24
+
25
+ def enqueue_delivery(delivery, **opts)
26
+ job.set(queue: queue, **opts).perform_later(
27
+ delivery.notifier_class.name,
28
+ delivery.action_name,
29
+ **delivery.delivery_params
30
+ )
31
+ end
32
+ end
33
+ end
34
+ end
35
+
36
+ AbstractNotifier.async_adapter ||= :active_job