active_delivery 1.0.0.rc2 → 1.0.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.
@@ -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