active_delivery 0.4.4 → 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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -0
  3. data/LICENSE.txt +19 -17
  4. data/README.md +595 -33
  5. data/lib/.rbnext/3.0/abstract_notifier/async_adapters/active_job.rb +27 -0
  6. data/lib/.rbnext/3.0/active_delivery/base.rb +248 -0
  7. data/lib/.rbnext/3.0/active_delivery/callbacks.rb +101 -0
  8. data/lib/.rbnext/3.0/active_delivery/lines/base.rb +89 -0
  9. data/lib/.rbnext/3.0/active_delivery/lines/mailer.rb +26 -0
  10. data/lib/.rbnext/3.0/active_delivery/testing.rb +62 -0
  11. data/lib/.rbnext/3.1/abstract_notifier/base.rb +217 -0
  12. data/lib/.rbnext/3.1/active_delivery/base.rb +248 -0
  13. data/lib/.rbnext/3.1/active_delivery/lines/base.rb +89 -0
  14. data/lib/abstract_notifier/async_adapters/active_job.rb +27 -0
  15. data/lib/abstract_notifier/async_adapters.rb +16 -0
  16. data/lib/abstract_notifier/base.rb +217 -0
  17. data/lib/abstract_notifier/callbacks.rb +94 -0
  18. data/lib/abstract_notifier/testing/minitest.rb +51 -0
  19. data/lib/abstract_notifier/testing/rspec.rb +164 -0
  20. data/lib/abstract_notifier/testing.rb +53 -0
  21. data/lib/abstract_notifier/version.rb +5 -0
  22. data/lib/abstract_notifier.rb +75 -0
  23. data/lib/active_delivery/base.rb +147 -27
  24. data/lib/active_delivery/callbacks.rb +25 -25
  25. data/lib/active_delivery/ext/string_constantize.rb +24 -0
  26. data/lib/active_delivery/lines/base.rb +42 -16
  27. data/lib/active_delivery/lines/mailer.rb +7 -18
  28. data/lib/active_delivery/lines/notifier.rb +53 -0
  29. data/lib/active_delivery/raitie.rb +9 -0
  30. data/lib/active_delivery/testing/rspec.rb +59 -12
  31. data/lib/active_delivery/testing.rb +19 -5
  32. data/lib/active_delivery/version.rb +1 -1
  33. data/lib/active_delivery.rb +8 -0
  34. metadata +63 -54
  35. data/.gem_release.yml +0 -3
  36. data/.github/ISSUE_TEMPLATE/bug_report.md +0 -24
  37. data/.github/ISSUE_TEMPLATE/feature_request.md +0 -24
  38. data/.github/PULL_REQUEST_TEMPLATE.md +0 -23
  39. data/.github/workflows/docs-lint.yml +0 -72
  40. data/.github/workflows/rspec-jruby.yml +0 -35
  41. data/.github/workflows/rspec.yml +0 -51
  42. data/.github/workflows/rubocop.yml +0 -21
  43. data/.gitignore +0 -43
  44. data/.mdlrc +0 -1
  45. data/.rspec +0 -2
  46. data/.rubocop-md.yml +0 -16
  47. data/.rubocop.yml +0 -28
  48. data/Gemfile +0 -17
  49. data/RELEASING.md +0 -43
  50. data/Rakefile +0 -20
  51. data/active_delivery.gemspec +0 -35
  52. data/forspell.dict +0 -8
  53. data/gemfiles/jruby.gemfile +0 -5
  54. data/gemfiles/rails42.gemfile +0 -8
  55. data/gemfiles/rails5.gemfile +0 -5
  56. data/gemfiles/rails50.gemfile +0 -8
  57. data/gemfiles/rails6.gemfile +0 -5
  58. data/gemfiles/railsmaster.gemfile +0 -6
  59. data/gemfiles/rubocop.gemfile +0 -4
  60. data/lefthook.yml +0 -18
  61. data/lib/active_delivery/action_mailer/parameterized.rb +0 -92
@@ -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
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
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
+
36
+ # Base class for deliveries.
37
+ #
38
+ # Delivery object describes how to notify a user about
39
+ # an event (e.g. via email or via push notification or both).
40
+ #
41
+ # Delivery class acts like a proxy in front of the different delivery channels
42
+ # (i.e. mailers, notifiers). That means that calling a method on delivery class invokes the
43
+ # same method on the corresponding class, e.g.:
44
+ #
45
+ # EventsDelivery.one_hour_before(profile, event).deliver_later
46
+ # # or
47
+ # EventsDelivery.notify(:one_hour_before, profile, event)
48
+ #
49
+ # # under the hood it calls
50
+ # EventsMailer.one_hour_before(profile, event).deliver_later
51
+ #
52
+ # # and
53
+ # EventsNotifier.one_hour_before(profile, event).notify_later
54
+ #
55
+ # Delivery also supports _parameterized_ calling:
56
+ #
57
+ # EventsDelivery.with(profile: profile).canceled(event).deliver_later
58
+ #
59
+ # The parameters could be accessed through `params` instance method (e.g.
60
+ # to implement guard-like logic).
61
+ #
62
+ # When params are presents the parametrized mailer is used, i.e.:
63
+ #
64
+ # EventsMailer.with(profile: profile).canceled(event).deliver_later
65
+ #
66
+ # See https://api.rubyonrails.org/classes/ActionMailer/Parameterized.html
67
+ class Base
68
+ class << self
69
+ attr_accessor :abstract_class
70
+
71
+ alias_method :with, :new
72
+
73
+ # Enqueues delivery (i.e. uses #deliver_later for mailers)
74
+ def notify(...)
75
+ new.notify(...)
76
+ end
77
+
78
+ # The same as .notify but delivers synchronously
79
+ # (i.e. #deliver_now for mailers)
80
+ def notify!(mid, *args, **hargs)
81
+ notify(mid, *args, **hargs, sync: true)
82
+ end
83
+
84
+ alias_method :notify_now, :notify!
85
+
86
+ def delivery_lines
87
+ @lines ||= if superclass.respond_to?(:delivery_lines)
88
+ superclass.delivery_lines.each_with_object({}) do |(key, val), acc|
89
+ acc[key] = val.dup_for(self)
90
+ end
91
+ else
92
+ {}
93
+ end
94
+ end
95
+
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
+
104
+ delivery_lines[line_id] = line_class.new(id: line_id, owner: self, **options)
105
+
106
+ instance_eval <<~CODE, __FILE__, __LINE__ + 1
107
+ def #{line_id}(val)
108
+ delivery_lines[:#{line_id}].handler_class_name = val
109
+ end
110
+
111
+ def #{line_id}_class
112
+ delivery_lines[:#{line_id}].handler_class
113
+ end
114
+ CODE
115
+ end
116
+
117
+ def unregister_line(line_id)
118
+ removed_line = delivery_lines.delete(line_id)
119
+
120
+ return if removed_line.nil?
121
+
122
+ singleton_class.undef_method line_id
123
+ singleton_class.undef_method "#{line_id}_class"
124
+ end
125
+
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
163
+ end
164
+
165
+ self.abstract_class = true
166
+
167
+ attr_reader :params, :notification_name
168
+
169
+ def initialize(**params)
170
+ @params = params
171
+ @params.freeze
172
+ end
173
+
174
+ # Enqueues delivery (i.e. uses #deliver_later for mailers)
175
+ def notify(mid, *args, **kwargs)
176
+ perform_notify(
177
+ delivery(notification: mid, params: args, options: kwargs)
178
+ )
179
+ end
180
+
181
+ # The same as .notify but delivers synchronously
182
+ # (i.e. #deliver_now for mailers)
183
+ def notify!(mid, *args, **kwargs)
184
+ perform_notify(
185
+ delivery(notification: mid, params: args, options: kwargs),
186
+ sync: true
187
+ )
188
+ end
189
+
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
199
+
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)
220
+ delivery_lines.each do |type, line|
221
+ next unless line.notify?(delivery.notification)
222
+
223
+ notify_line(type, line, delivery, sync: sync)
224
+ end
225
+ end
226
+
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)
242
+ end
243
+
244
+ def delivery_lines
245
+ self.class.delivery_lines
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ unless "".respond_to?(:safe_constantize)
4
+ require "active_delivery/ext/string_constantize"
5
+ using ActiveDelivery::Ext::StringConstantize
6
+ end
7
+
8
+ module ActiveDelivery
9
+ module Lines
10
+ class Base
11
+ attr_reader :id, :options
12
+ attr_accessor :owner
13
+ attr_accessor :handler_class_name
14
+
15
+ def initialize(id:, owner:, **options)
16
+ @id = id
17
+ @owner = owner
18
+ @options = options.tap(&:freeze)
19
+ @resolver = options[:resolver] || build_pattern_resolver(options[:resolver_pattern])
20
+ end
21
+
22
+ def dup_for(new_owner)
23
+ self.class.new(id: id, **options, owner: new_owner)
24
+ end
25
+
26
+ def resolve_class(name)
27
+ resolver&.call(name)
28
+ end
29
+
30
+ def notify?(method_name)
31
+ handler_class&.respond_to?(method_name)
32
+ end
33
+
34
+ def notify_now(handler, mid, ...)
35
+ end
36
+
37
+ def notify_later(handler, mid, ...)
38
+ end
39
+
40
+ def notify(mid, *args, params:, sync:, **kwargs)
41
+ clazz = params.empty? ? handler_class : handler_class.with(**params)
42
+ sync ? notify_now(clazz, mid, *args, **kwargs) : notify_later(clazz, mid, *args, **kwargs)
43
+ end
44
+
45
+ def handler_class
46
+ if ::ActiveDelivery.cache_classes
47
+ return @handler_class if instance_variable_defined?(:@handler_class)
48
+ end
49
+
50
+ return @handler_class = nil if owner.abstract_class?
51
+
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
63
+ end
64
+
65
+ private
66
+
67
+ attr_reader :resolver
68
+
69
+ def build_pattern_resolver(pattern)
70
+ return unless pattern
71
+
72
+ proc do |delivery|
73
+ delivery_class = delivery.name
74
+
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
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,27 @@
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
15
+
16
+ def initialize(queue: DEFAULT_QUEUE, job: DeliveryJob)
17
+ @job = job.set(queue: queue)
18
+ end
19
+
20
+ def enqueue(...)
21
+ job.perform_later(...)
22
+ end
23
+ end
24
+ end
25
+ end
26
+
27
+ AbstractNotifier.async_adapter ||= :active_job
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AbstractNotifier
4
+ module AsyncAdapters
5
+ class << self
6
+ def lookup(adapter, options = nil)
7
+ return adapter unless adapter.is_a?(Symbol)
8
+
9
+ adapter_class_name = adapter.to_s.split("_").map(&:capitalize).join
10
+ AsyncAdapters.const_get(adapter_class_name).new(**(options || {}))
11
+ rescue NameError => e
12
+ raise e.class, "Notifier async adapter :#{adapter} haven't been found", e.backtrace
13
+ end
14
+ end
15
+ end
16
+ end