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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e9c65eedb249c1a52253ad6e2155271e8931b55e2e7e429baf9f72682a4bd60a
4
- data.tar.gz: '048565d99b6da3903905740b90c726f1805376237e7af4cb7f526b83bea0857e'
3
+ metadata.gz: dfc96638e6c019dd1b13f57eef8ab3b9bf2a34df2b58ca7f7ea8ea167886c59e
4
+ data.tar.gz: b50d556edef4a01fc7d1a8af1659a05f99a821584c2d4818418401f3a03350dd
5
5
  SHA512:
6
- metadata.gz: 0c753da1c2b0abf43e3361eff188c87e2f3eefa9f1c1a35c3a238810b8fd399e0f91196c5af94a3d50a66ccd1439adb166be95e416b531b00ffa4021550c1608
7
- data.tar.gz: 225e522994457b30caad32e7ae5d0c93df5f2bca295fe63c460d3aa87f52478d387fc30950bc0b95a1f5ce93e42352588bc5310075bd55765cd67bb31b6e839f
6
+ metadata.gz: 518d226591f5114383c2d138c163b4bac13e62666cce4f7edde7d2df5bca7e770c17576f00fee378b9a8733cc72372960efa2692839a6dc4d274d130787fcb41
7
+ data.tar.gz: 2bd5eed0775fd32d83ca45364d8ecdc4f28c275eb2ffbbcad768ef98d8581b1cd0e66eb72f7e012c9d4dbd0a18e8a72cb40a858f0459614e18a5ee3e24fb0420
data/CHANGELOG.md CHANGED
@@ -2,47 +2,75 @@
2
2
 
3
3
  ## master
4
4
 
5
+ ## 1.1.0 (2023-12-01) ❄️
6
+
7
+ - Support delayed delivery options (e.g, `wait_until`). ([@palkan][])
8
+
9
+ ## 📬 1.0.0 (2023-08-29)
10
+
11
+ - Add `resolver_pattern` option to specify naming pattern for notifiers without using Procs. ([@palkan][])
12
+
13
+ - [!IMPORTANT] Notifier's `#notify_later` now do not process the action right away, only enqueue the job. ([@palkan][]).
14
+
15
+ This matches the Action Mailer behaviour. Now, the action is only invoked before the delivery attempt.
16
+
17
+ - Add callbacks support to Abstract Notifier (`before_action`, `after_deliver`, etc.). ([@palkan][])
18
+
5
19
  - **Merge in abstract_notifier** ([@palkan][])
6
20
 
7
- [Abstract Notifier](https://github.com/palkan/abstract_notifier) is now a part of Active Delivery.
21
+ [Abstract Notifier](https://github.com/palkan/abstract_notifier) is now a part of Active Delivery.
8
22
 
9
23
  - Add ability to specify delivery actions explicitly and disable implicit proxying. ([@palkan][])
10
24
 
11
- You can disable default Active Delivery behaviour of proxying action methods to underlying lines via the `ActiveDelivery.deliver_actions_required = true` configuration option. Then, in each delivery class, you can specify the available actions via the `.delivers` method:
25
+ You can disable default Active Delivery behaviour of proxying action methods to underlying lines via the `ActiveDelivery.deliver_actions_required = true` configuration option. Then, in each delivery class, you can specify the available actions via the `.delivers` method:
12
26
 
13
- ```ruby
14
- class PostMailer < ApplicationMailer
15
- def published(post)
16
- # ...
17
- end
27
+ ```ruby
28
+ class PostMailer < ApplicationMailer
29
+ def published(post)
30
+ # ...
31
+ end
18
32
 
19
- def whatever(post)
20
- # ...
33
+ def whatever(post)
34
+ # ...
35
+ end
21
36
  end
22
- end
23
37
 
24
- ActiveDelivery.deliver_actions_required = true
38
+ ActiveDelivery.deliver_actions_required = true
25
39
 
26
- class PostDelivery < ApplicationDelivery
27
- delivers :published
28
- end
40
+ class PostDelivery < ApplicationDelivery
41
+ delivers :published
42
+ end
29
43
 
30
- PostDelivery.published(post) #=> ok
31
- PostDelivery.whatever(post) #=> raises NoMethodError
32
- ```
44
+ PostDelivery.published(post) #=> ok
45
+ PostDelivery.whatever(post) #=> raises NoMethodError
46
+ ```
33
47
 
34
48
  - Add `#deliver_via(*lines)` RSpec matcher. ([@palkan][])
35
49
 
50
+ - **BREAKING** The `#resolve_class` method in Line classes now receive a delivery class instead of a name:
51
+
52
+ ```ruby
53
+ # before
54
+ def resolve_class(name)
55
+ name.gsub(/Delivery$/, "Channel").safe_constantize
56
+ end
57
+
58
+ # after
59
+ def resolve_class(name)
60
+ name.to_s.gsub(/Delivery$/, "Channel").safe_constantize
61
+ end
62
+ ```
63
+
36
64
  - Provide ActionMailer-like interface to trigger notifications. ([@palkan][])
37
65
 
38
- Now you can send notifications as follows:
66
+ Now you can send notifications as follows:
39
67
 
40
- ```ruby
41
- MyDelivery.with(user:).new_notification(payload).deliver_later
68
+ ```ruby
69
+ MyDelivery.with(user:).new_notification(payload).deliver_later
42
70
 
43
- # Equals to the old (and still supported)
44
- MyDelivery.with(user:).notify(:new_notification, payload)
45
- ```
71
+ # Equals to the old (and still supported)
72
+ MyDelivery.with(user:).notify(:new_notification, payload)
73
+ ```
46
74
 
47
75
  - Support passing a string class name as a handler class. ([@palkan][])
48
76
 
data/README.md CHANGED
@@ -79,8 +79,16 @@ class ApplicationDelivery < ActiveDelivery::Base
79
79
  # Mailers are enabled by default, everything else must be declared explicitly
80
80
 
81
81
  # For example, you can use a notifier line (see below) with a custom resolver
82
+ # (the argument is the delivery class)
82
83
  register_line :sms, ActiveDelivery::Lines::Notifier,
83
- resolver: -> { _1.name.gsub(/Delivery$/, "SMSNotifier").safe_constantize }
84
+ resolver: -> { _1.name.gsub(/Delivery$/, "SMSNotifier").safe_constantize } #=> PostDelivery -> PostSMSNotifier
85
+
86
+ # Or you can use a name pattern to resolve notifier classes for delivery classes
87
+ # Available placeholders are:
88
+ # - delivery_class — full delivery class name
89
+ # - delivery_name — full delivery class name without the "Delivery" suffix
90
+ register_line :webhook, ActiveDelivery::Lines::Notifier,
91
+ resolver_pattern: "%{delivery_name}WebhookNotifier" #=> PostDelivery -> PostWebhookNotifier
84
92
 
85
93
  register_line :cable, ActionCableDeliveryLine
86
94
  # and more
@@ -105,6 +113,9 @@ PostsDelivery.published(user, post).deliver_later
105
113
  PostsMailer.published(user, post).deliver_later
106
114
  PostsSMSNotifier.published(user, post).notify_later
107
115
 
116
+ # You can also pass options supported by your async executor (such as ActiveJob)
117
+ PostsDelivery.published(user, post).deliver_later(wait_until: 1.day.from_now)
118
+
108
119
  # and whaterver your ActionCableDeliveryLine does
109
120
  # under the hood.
110
121
  ```
@@ -153,6 +164,45 @@ PostDelivery.published(post) #=> ok
153
164
  PostDelivery.whatever(post) #=> raises NoMethodError
154
165
  ```
155
166
 
167
+ ### Organizing delivery and notifier classes
168
+
169
+ There are two common ways to organize delivery and notifier classes in your codebase:
170
+
171
+ ```txt
172
+ app/
173
+ deliveries/ deliveries/
174
+ application_delivery.rb application_delivery.rb
175
+ post_delivery.rb post_delivery/
176
+ user_delivery.rb post_mailer.rb
177
+ mailers/ post_sms_notifier.rb
178
+ application_mailer.rb post_webhook_notifier.rb
179
+ post_mailer.rb post_delivery.rb
180
+ user_mailer.rb user_delivery/
181
+ notifiers/ user_mailer.rb
182
+ application_notifier.rb user_sms_notifier.rb
183
+ post_sms_notifier.rb user_webhook_notifier.rb
184
+ post_webhook_notifier.rb user_delivery.rb
185
+ user_sms_notifier.rb
186
+ user_webhook_notifier.rb
187
+ ```
188
+
189
+ The left side is a _flat_ structure, more typical for classic Rails applications. The right side follows the _sidecar pattern_ and aims to localize all the code related to a specific delivery class in a single directory. To use the sidecar version, you need to configure your delivery lines as follows:
190
+
191
+ ```ruby
192
+ class ApplicationDelivery < ActiveDelivery::Base
193
+ self.abstract_class = true
194
+
195
+ register_line :mailer, ActiveDelivery::Lines::Mailer,
196
+ resolver_pattern: "%{delivery_class}::%{delivery_name}_mailer"
197
+ register_line :sms,
198
+ notifier: true,
199
+ resolver_pattern: "%{delivery_class}::%{delivery_name}_sms_notifier"
200
+ register_line :webhook,
201
+ notifier: true,
202
+ resolver_pattern: "%{delivery_class}::%{delivery_name}_webhook_notifier"
203
+ end
204
+ ```
205
+
156
206
  ### Customizing delivery handlers
157
207
 
158
208
  You can specify a mailer class explicitly:
@@ -659,7 +709,7 @@ end
659
709
 
660
710
  ### Background jobs / async notifications
661
711
 
662
- To use `#notify_later` you **must** configure an async adapter for Abstract Notifier.
712
+ To use `#notify_later(**delivery_options)` you **must** configure an async adapter for Abstract Notifier.
663
713
 
664
714
  We provide an Active Job adapter out of the box and enable it if Active Job is found.
665
715
 
@@ -671,11 +721,14 @@ class MyAsyncAdapter
671
721
  def initialize(options = {})
672
722
  end
673
723
 
674
- # `enqueue` method accepts notifier class and notification
675
- # payload.
676
- # We need to know notifier class to use its driver.
677
- def enqueue(notifier_class, payload)
678
- # your implementation here
724
+ # `enqueue_delivery` method accepts notifier class, action name and notification parameters
725
+ def enqueue_delivery(delivery, **options)
726
+ # <Your implementation here>
727
+ # To trigger the notification delivery, you can use the following snippet:
728
+ #
729
+ # AbstractNotifier::NotificationDelivery.new(
730
+ # delivery.notifier_class, delivery.action_name, **delivery.delivery_params
731
+ # ).notify_now
679
732
  end
680
733
  end
681
734
 
@@ -688,6 +741,53 @@ class EventsNotifier < AbstractNotifier::Base
688
741
  end
689
742
  ```
690
743
 
744
+ ### Action and Delivery Callbacks
745
+
746
+ **NOTE:** callbacks are only available if ActiveSupport is present in the application's runtime.
747
+
748
+ ```ruby
749
+ # Run method before building a notification payload
750
+ # NOTE: when `false` is returned the execution is halted
751
+ before_action :do_something
752
+
753
+ # Run method before delivering notification
754
+ # NOTE: when `false` is returned the execution is halted
755
+ before_deliver :do_something
756
+
757
+ # Run method after the notification payload was build but before delivering
758
+ after_action :verify_notification_payload
759
+
760
+ # Run method after the actual delivery was performed
761
+ after_deliver :mark_user_as_notified, if: -> { params[:user].present? }
762
+
763
+ # after_ and around_ callbacks are also supported
764
+ after_action_ :cleanup
765
+
766
+ around_deliver :set_context
767
+
768
+ # You can also skip callbacks in sub-classes
769
+ skip_before_action :do_something, only: %i[some_reminder]
770
+ ```
771
+
772
+ Example:
773
+
774
+ ```ruby
775
+ class MyNotifier < AbstractNotifier::Base
776
+ # Log sent notifications
777
+ after_deliver do
778
+ # You can access the notification name within the instance or
779
+ MyLogger.info "Notification sent: #{notification_name}"
780
+ end
781
+
782
+ def some_event(body)
783
+ notification(body:)
784
+ end
785
+ end
786
+
787
+ MyNotifier.some_event("hello")
788
+ #=> Notification sent: some_event
789
+ ```
790
+
691
791
  ### Delivery modes
692
792
 
693
793
  For test/development purposes there are two special _global_ delivery modes:
@@ -772,6 +872,9 @@ class ApplicationDelivery < ActiveDelivery::Base
772
872
  # `*Delivery` -> `*CustomNotifier`
773
873
  register_line :custom_notifier, notifier: true, suffix: "CustomNotifier"
774
874
 
875
+ # Or using a custom pattern
876
+ register_line :custom_notifier, notifier: true, resolver_pattern: "%{delivery_name}CustomNotifier"
877
+
775
878
  # Or you can specify a Proc object to do custom resolution:
776
879
  register_line :some_notifier, notifier: true,
777
880
  resolver: ->(delivery_class) { resolve_somehow(delivery_class) }
@@ -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, *__rest__, &__block__)
8
+ AbstractNotifier::NotificationDelivery.new(notifier_class.constantize, *__rest__, &__block__).notify_now
9
+ end; respond_to?(:ruby2_keywords, true) && (ruby2_keywords :perform)
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; respond_to?(:ruby2_keywords, true) && (ruby2_keywords :enqueue)
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
@@ -0,0 +1,223 @@
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, :notifier_class
9
+
10
+ def initialize(notifier_class, action_name, params: {}, args: [], kwargs: {})
11
+ @notifier_class = notifier_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(**opts)
27
+ if notifier_class.async_adapter.respond_to?(:enqueue_delivery)
28
+ notifier_class.async_adapter.enqueue_delivery(self, **opts)
29
+ else
30
+ notifier_class.async_adapter.enqueue(notifier_class.name, action_name, params: params, args: args, kwargs: kwargs)
31
+ end
32
+ end
33
+
34
+ def notify_now
35
+ return unless notification.payload
36
+
37
+ notifier.deliver!(notification)
38
+ end
39
+
40
+ def delivery_params ; {params: params, args: args, kwargs: kwargs}; end
41
+
42
+ private
43
+
44
+ attr_reader :params, :args, :kwargs
45
+
46
+ def notifier
47
+ @notifier ||= notifier_class.new(action_name, **params)
48
+ end
49
+ end
50
+
51
+ # Notification object contains the compiled payload to be delivered
52
+ class Notification
53
+ attr_reader :payload
54
+
55
+ def initialize(payload)
56
+ @payload = payload
57
+ end
58
+ end
59
+
60
+ # Base class for notifiers
61
+ class Base
62
+ class ParamsProxy
63
+ attr_reader :notifier_class, :params
64
+
65
+ def initialize(notifier_class, params)
66
+ @notifier_class = notifier_class
67
+ @params = params
68
+ end
69
+
70
+ # rubocop:disable Style/MethodMissingSuper
71
+ def method_missing(method_name, *args, **kwargs)
72
+ NotificationDelivery.new(notifier_class, method_name, params: params, args: args, kwargs: kwargs)
73
+ end
74
+ # rubocop:enable Style/MethodMissingSuper
75
+
76
+ def respond_to_missing?(*args)
77
+ notifier_class.respond_to_missing?(*args)
78
+ end
79
+ end
80
+
81
+ class << self
82
+ attr_writer :driver
83
+
84
+ def driver
85
+ return @driver if instance_variable_defined?(:@driver)
86
+
87
+ @driver =
88
+ if superclass.respond_to?(:driver)
89
+ superclass.driver
90
+ else
91
+ raise "Driver not found for #{name}. " \
92
+ "Please, specify driver via `self.driver = MyDriver`"
93
+ end
94
+ end
95
+
96
+ def async_adapter=(args)
97
+ adapter, options = Array(args)
98
+ @async_adapter = AsyncAdapters.lookup(adapter, options)
99
+ end
100
+
101
+ def async_adapter
102
+ return @async_adapter if instance_variable_defined?(:@async_adapter)
103
+
104
+ @async_adapter =
105
+ if superclass.respond_to?(:async_adapter)
106
+ superclass.async_adapter
107
+ else
108
+ AbstractNotifier.async_adapter
109
+ end
110
+ end
111
+
112
+ def default(method_name = nil, **hargs, &block)
113
+ return @defaults_generator = block if block
114
+
115
+ return @defaults_generator = proc { send(method_name) } unless method_name.nil?
116
+
117
+ @default_params =
118
+ if superclass.respond_to?(:default_params)
119
+ superclass.default_params.merge(hargs).freeze
120
+ else
121
+ hargs.freeze
122
+ end
123
+ end
124
+
125
+ def defaults_generator
126
+ return @defaults_generator if instance_variable_defined?(:@defaults_generator)
127
+
128
+ @defaults_generator =
129
+ if superclass.respond_to?(:defaults_generator)
130
+ superclass.defaults_generator
131
+ end
132
+ end
133
+
134
+ def default_params
135
+ return @default_params if instance_variable_defined?(:@default_params)
136
+
137
+ @default_params =
138
+ if superclass.respond_to?(:default_params)
139
+ superclass.default_params.dup
140
+ else
141
+ {}
142
+ end
143
+ end
144
+
145
+ def method_missing(method_name, *args, **kwargs)
146
+ if action_methods.include?(method_name.to_s)
147
+ NotificationDelivery.new(self, method_name, args: args, kwargs: kwargs)
148
+ else
149
+ super
150
+ end
151
+ end
152
+
153
+ def with(params)
154
+ ParamsProxy.new(self, params)
155
+ end
156
+
157
+ def respond_to_missing?(method_name, _include_private = false)
158
+ action_methods.include?(method_name.to_s) || super
159
+ end
160
+
161
+ # See https://github.com/rails/rails/blob/b13a5cb83ea00d6a3d71320fd276ca21049c2544/actionpack/lib/abstract_controller/base.rb#L74
162
+ def action_methods
163
+ @action_methods ||= begin
164
+ # All public instance methods of this class, including ancestors
165
+ methods = (public_instance_methods(true) -
166
+ # Except for public instance methods of Base and its ancestors
167
+ Base.public_instance_methods(true) +
168
+ # Be sure to include shadowed public instance methods of this class
169
+ public_instance_methods(false))
170
+
171
+ methods.map!(&:to_s)
172
+
173
+ methods.to_set
174
+ end
175
+ end
176
+ end
177
+
178
+ attr_reader :params, :notification_name
179
+
180
+ def initialize(notification_name, **params)
181
+ @notification_name = notification_name
182
+ @params = params.freeze
183
+ end
184
+
185
+ def process_action(...)
186
+ public_send(...)
187
+ end; respond_to?(:ruby2_keywords, true) && (ruby2_keywords :process_action)
188
+
189
+ def deliver!(notification)
190
+ self.class.driver.call(notification.payload)
191
+ end
192
+
193
+ def notification(**payload)
194
+ merge_defaults!(payload)
195
+
196
+ payload[:body] = implicit_payload_body unless payload.key?(:body)
197
+
198
+ raise ArgumentError, "Notification body must be present" if
199
+ payload[:body].nil? || payload[:body].empty?
200
+
201
+ @notification = Notification.new(payload)
202
+ end
203
+
204
+ private
205
+
206
+ def implicit_payload_body
207
+ # no-op — override to provide custom logic
208
+ end
209
+
210
+ def merge_defaults!(payload)
211
+ defaults =
212
+ if self.class.defaults_generator
213
+ instance_exec(&self.class.defaults_generator)
214
+ else
215
+ self.class.default_params
216
+ end
217
+
218
+ defaults.each do |k, v|
219
+ payload[k] = v unless payload.key?(k)
220
+ end
221
+ end
222
+ end
223
+ end