action_webhook 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 2cd12e0bb7797fc35e1f987a3af410dd2a4389477d8170f3ab3865aa333d3ecf
4
+ data.tar.gz: 2b5e65c5f463f643dd9081d7dc9d899d289211da93d4ccd5708797d193bb4066
5
+ SHA512:
6
+ metadata.gz: 382b8c38b35ea02f8a0dc1ba62ff4df47acf14e30b70188fe0a5ebc0a1558bb1d25e75715de907f1eaa1e8ac8ac29825575fb7db5081a30202f5314bad4831bf
7
+ data.tar.gz: 80d3a48160158dfe614404a146280dcadd22040b89d18d0f3d0461f0bf04db3819aacf5985e33bee0dfc8e257836ff74dc46c5774a9c0b5df72da1cb09eb6a69
@@ -0,0 +1,431 @@
1
+ module ActionWebhook
2
+ # Base class for defining and delivering webhooks
3
+ #
4
+ # Subclass this and define webhook methods (e.g. `created`, `updated`) that
5
+ # define instance variables and call deliver to send webhooks.
6
+ #
7
+ # Example:
8
+ #
9
+ # class UserWebhook < ActionWebhook::Base
10
+ # def created(user)
11
+ # @user = user
12
+ # # Get webhook endpoints from your database or config
13
+ # endpoints = WebhookSubscription.where(event: 'user.created').map do |sub|
14
+ # { url: sub.url, headers: { 'Authorization' => "Bearer #{sub.token}" } }
15
+ # end
16
+ # deliver(endpoints)
17
+ # end
18
+ # end
19
+ #
20
+ # Then in your controller or model:
21
+ #
22
+ # # Send immediately
23
+ # UserWebhook.created(user).deliver_now
24
+ #
25
+ # # Send in background
26
+ # UserWebhook.created(user).deliver_later
27
+ #
28
+ class Base
29
+ # Add these lines near the top of your class
30
+ include GlobalID::Identification if defined?(GlobalID)
31
+ include ActiveJob::SerializationAdapter::ObjectSerializer if defined?(ActiveJob::SerializationAdapter)
32
+
33
+ # Delivery configuration
34
+ class_attribute :delivery_job, instance_writer: false, default: -> { "ActionWebhook::DeliveryJob".constantize }
35
+ class_attribute :deliver_later_queue_name, instance_writer: false
36
+ class_attribute :default_headers, instance_writer: false, default: {}
37
+ class_attribute :delivery_method, instance_writer: false, default: :deliver_now
38
+ class_attribute :perform_deliveries, instance_writer: false, default: true
39
+
40
+ # Retry configuration
41
+ class_attribute :max_retries, instance_writer: false, default: 3
42
+ class_attribute :retry_delay, instance_writer: false, default: 30.seconds
43
+ class_attribute :retry_backoff, instance_writer: false, default: :exponential # :linear or :exponential
44
+ class_attribute :retry_jitter, instance_writer: false, default: 5.seconds
45
+
46
+ # Callbacks
47
+ class_attribute :after_deliver_callback, instance_writer: false
48
+ class_attribute :after_retries_exhausted_callback, instance_writer: false
49
+
50
+ # The webhook action that will be performed
51
+ attr_accessor :action_name
52
+
53
+ # The webhook details (URLs and headers) that will be used
54
+ attr_accessor :webhook_details
55
+
56
+ # Stores the execution params for later delivery
57
+ attr_accessor :params
58
+
59
+ # Current attempt number for retries
60
+ attr_accessor :attempts
61
+
62
+ # Creates a new webhook and initializes its settings
63
+ def initialize
64
+ @_webhook_message = {}
65
+ @_webhook_defaults = {}
66
+ @attempts = 0
67
+ end
68
+
69
+ # Synchronously delivers the webhook
70
+ def deliver_now
71
+ @attempts += 1
72
+ response = process_webhook
73
+
74
+ # Call success callback if defined
75
+ if response.all? { |r| r[:success] }
76
+ invoke_callback(self.class.after_deliver_callback, response)
77
+ elsif @attempts < self.class.max_retries
78
+ # Schedule a retry with backoff
79
+ retry_with_backoff
80
+ else
81
+ # We've exhausted all retries
82
+ invoke_callback(self.class.after_retries_exhausted_callback, response)
83
+ end
84
+
85
+ response
86
+ end
87
+
88
+ # Helper method to invoke a callback that might be a symbol or a proc
89
+ def invoke_callback(callback, response)
90
+ return unless callback
91
+
92
+ case callback
93
+ when Symbol
94
+ send(callback, response)
95
+ when Proc
96
+ callback.call(self, response)
97
+ end
98
+ end
99
+
100
+ # Enqueues the webhook delivery via ActiveJob
101
+ def deliver_later(options = {})
102
+ enqueue_delivery(:deliver_now, options)
103
+ end
104
+
105
+ # Prepares the webhook for delivery using the current method as template name
106
+ # and instance variables as template data
107
+ #
108
+ # @param webhook_details [Array<Hash>] Array of hashes with :url and :headers keys
109
+ # @param params [Hash] Optional parameters to store for later delivery
110
+ # @return [DeliveryMessenger] A messenger object for further delivery options
111
+ def deliver(webhook_details, params = {})
112
+ # Determine action name from the caller
113
+ @action_name = caller_locations(1, 1)[0].label.to_sym
114
+ @webhook_details = webhook_details
115
+ @params = params
116
+
117
+ # Return self for chaining with delivery methods
118
+ DeliveryMessenger.new(self)
119
+ end
120
+
121
+ # Renders a template based on the action name
122
+ #
123
+ # @param variables [Hash] Optional variables to add to template context
124
+ # @return [Hash] The JSON payload
125
+ def build_payload(variables = {})
126
+ # Combine instance variables and passed variables
127
+ assigns = {}
128
+
129
+ # Extract instance variables
130
+ instance_variables.each do |ivar|
131
+ # Skip internal instance variables
132
+ next if ivar.to_s.start_with?("@_")
133
+ next if [:@action_name, :@webhook_details, :@params, :@attempts].include?(ivar)
134
+
135
+ # Add to assigns with symbol key (without @)
136
+ assigns[ivar.to_s[1..-1].to_sym] = instance_variable_get(ivar)
137
+ end
138
+
139
+ # Add passed variables
140
+ assigns.merge!(variables)
141
+
142
+ # Render the template
143
+ generate_json_from_template(@action_name, assigns)
144
+ end
145
+
146
+ # Posts payload(s) to the given webhook endpoints
147
+ #
148
+ # @param webhook_details [Array<Hash>] An array of hashes containing `url` and `headers`
149
+ # @param payloads [Array<Hash>] One payload for each webhook
150
+ # @return [Array<Hash>] Array of response objects with status and body
151
+ def post_webhook(webhook_details, payloads)
152
+ responses = []
153
+
154
+ webhook_details.each_with_index do |detail, idx|
155
+ begin
156
+ # Ensure headers exists
157
+ detail[:headers] ||= {}
158
+
159
+ # Merge default headers
160
+ headers = default_headers.merge(detail[:headers])
161
+
162
+ # Add content type if not present
163
+ headers["Content-Type"] = "application/json" unless headers.key?("Content-Type")
164
+
165
+ # Add attempt tracking in headers
166
+ headers["X-Webhook-Attempt"] = @attempts.to_s if @attempts > 0
167
+
168
+ response = HTTParty.post(
169
+ detail[:url],
170
+ body: payloads[idx].to_json,
171
+ headers: headers,
172
+ timeout: 10 # Add reasonable timeout
173
+ )
174
+
175
+ responses << {
176
+ success: response.success?,
177
+ status: response.code,
178
+ body: response.body,
179
+ url: detail[:url],
180
+ attempt: @attempts
181
+ }
182
+ rescue => e
183
+ responses << {
184
+ success: false,
185
+ error: e.message,
186
+ url: detail[:url],
187
+ attempt: @attempts
188
+ }
189
+ logger.error("Webhook delivery failed: #{e.message} for URL: #{detail[:url]} (Attempt #{@attempts})")
190
+ end
191
+ end
192
+
193
+ responses
194
+ end
195
+
196
+ # Renders a JSON payload from a `.json.erb` template
197
+ #
198
+ # @param event_name [Symbol] the name of the webhook method (e.g. `:created`)
199
+ # @param assigns [Hash] local variables to pass into the template
200
+ # @return [Hash] the parsed JSON payload
201
+ def generate_json_from_template(event_name, assigns = {})
202
+ webhook_class_name = self.class.name.underscore
203
+
204
+ # Possible template locations
205
+ possible_paths = [
206
+ # Main app templates
207
+ File.join(Rails.root.to_s, "app/webhooks/#{webhook_class_name}/#{event_name}.json.erb"),
208
+
209
+ # Engine templates
210
+ engine_root && File.join(engine_root, "app/webhooks/#{webhook_class_name}/#{event_name}.json.erb"),
211
+
212
+ # Namespaced templates in engine
213
+ engine_root && self.class.module_parent != Object &&
214
+ File.join(engine_root, "app/webhooks/#{self.class.module_parent.name.underscore}/#{webhook_class_name.split('/').last}/#{event_name}.json.erb")
215
+ ].compact
216
+
217
+ # Find the first template that exists
218
+ template_path = possible_paths.find { |path| File.exist?(path) }
219
+
220
+ unless template_path
221
+ raise ArgumentError, "Template not found for #{event_name} in paths:\n#{possible_paths.join("\n")}"
222
+ end
223
+
224
+ template = ERB.new(File.read(template_path))
225
+ json = template.result_with_hash(assigns)
226
+ JSON.parse(json)
227
+ rescue JSON::ParserError => e
228
+ raise "Invalid JSON in template #{event_name}: #{e.message}"
229
+ end
230
+
231
+ # Process the webhook to generate and send the payload
232
+ def process_webhook
233
+ # Render the message
234
+ payloads = [build_payload]
235
+
236
+ # Post the webhook
237
+ post_webhook(webhook_details, payloads)
238
+ end
239
+
240
+ # Schedule a retry with appropriate backoff delay
241
+ # Modify the retry_with_backoff method:
242
+ def retry_with_backoff
243
+ delay = calculate_backoff_delay
244
+
245
+ logger.info("Scheduling webhook retry #{@attempts + 1}/#{self.class.max_retries} in #{delay} seconds")
246
+
247
+ # Get the actual job class by evaluating the proc
248
+ job_class = self.class.delivery_job.is_a?(Proc) ? self.class.delivery_job.call : self.class.delivery_job
249
+
250
+ # Serialize the webhook and pass the serialized data instead of the object
251
+ serialized_webhook = serialize
252
+
253
+ # Re-enqueue with the calculated delay
254
+ if deliver_later_queue_name
255
+ job_class.set(queue: deliver_later_queue_name, wait: delay).perform_later('deliver_now', serialized_webhook)
256
+ else
257
+ job_class.set(wait: delay).perform_later('deliver_now', serialized_webhook)
258
+ end
259
+ end
260
+
261
+ # Calculate delay based on retry strategy
262
+ def calculate_backoff_delay
263
+ base_delay = self.class.retry_delay
264
+
265
+ case self.class.retry_backoff
266
+ when :exponential
267
+ # 30s, 60s, 120s, etc.
268
+ delay = base_delay * (2 ** (@attempts - 1))
269
+ when :linear
270
+ # 30s, 60s, 90s, etc.
271
+ delay = base_delay * @attempts
272
+ else
273
+ delay = base_delay
274
+ end
275
+
276
+ # Add jitter to prevent thundering herd problem
277
+ jitter = rand(self.class.retry_jitter)
278
+ delay + jitter
279
+ end
280
+
281
+ # For ActiveJob serialization
282
+ def serialize
283
+ {
284
+ 'action_name' => @action_name.to_s,
285
+ 'webhook_details' => @webhook_details,
286
+ 'params' => @params,
287
+ 'attempts' => @attempts,
288
+ 'instance_variables' => collect_instance_variables,
289
+ 'webhook_class' => self.class.name
290
+ }
291
+ end
292
+
293
+ # Restores state from serialized data
294
+ def deserialize(data)
295
+ @action_name = data['action_name'].to_sym
296
+ @webhook_details = data['webhook_details']
297
+ @params = data['params']
298
+ @attempts = data['attempts'] || 0
299
+
300
+ # Restore instance variables
301
+ data['instance_variables'].each do |name, value|
302
+ instance_variable_set("@#{name}", value)
303
+ end
304
+ end
305
+
306
+ private
307
+
308
+ # Collects all non-system instance variables for serialization
309
+ def collect_instance_variables
310
+ result = {}
311
+ instance_variables.each do |ivar|
312
+ # Skip internal instance variables
313
+ next if ivar.to_s.start_with?("@_")
314
+ next if [:@action_name, :@webhook_details, :@params, :@attempts].include?(ivar)
315
+
316
+ # Add to result without @
317
+ result[ivar.to_s[1..-1]] = instance_variable_get(ivar)
318
+ end
319
+ result
320
+ end
321
+
322
+ # Find the engine root path
323
+ def engine_root
324
+ return nil unless defined?(Rails::Engine)
325
+
326
+ mod = self.class.module_parent
327
+ while mod != Object
328
+ constants = mod.constants.select { |c|
329
+ const = mod.const_get(c)
330
+ const.is_a?(Class) && const < Rails::Engine
331
+ }
332
+
333
+ return mod.const_get(constants.first).root.to_s if constants.any?
334
+ mod = mod.module_parent
335
+ end
336
+
337
+ nil
338
+ end
339
+
340
+ # Similarly update enqueue_delivery:
341
+ def enqueue_delivery(delivery_method, options = {})
342
+ options = options.dup
343
+ queue = options.delete(:queue) || self.class.deliver_later_queue_name
344
+
345
+ args = [delivery_method.to_s]
346
+
347
+ # Get the actual job class by evaluating the proc
348
+ job_class = self.class.delivery_job.is_a?(Proc) ? self.class.delivery_job.call : self.class.delivery_job
349
+
350
+ # Serialize the webhook
351
+ serialized_webhook = serialize
352
+
353
+ # Use the delivery job to perform the delivery with serialized data
354
+ if queue
355
+ job_class.set(queue: queue).perform_later(*args, serialized_webhook)
356
+ else
357
+ job_class.set(wait: options[:wait]).perform_later(*args, serialized_webhook) if options[:wait]
358
+ job_class.perform_later(*args, serialized_webhook) unless options[:wait]
359
+ end
360
+ end
361
+
362
+ # Logger for webhook errors
363
+ def logger
364
+ Rails.logger
365
+ end
366
+
367
+ # Class methods
368
+ class << self
369
+ # Handle method calls on the class
370
+ def method_missing(method_name, *args, &block)
371
+ if public_instance_methods(false).include?(method_name)
372
+ # Create a new instance
373
+ webhook = new
374
+
375
+ # Call the instance method
376
+ webhook.send(method_name, *args, &block)
377
+ else
378
+ super
379
+ end
380
+ end
381
+
382
+ def respond_to_missing?(method_name, include_private = false)
383
+ public_instance_methods(false).include?(method_name) || super
384
+ end
385
+
386
+ # Test delivery collection
387
+ def deliveries
388
+ @deliveries ||= []
389
+ end
390
+
391
+ # Reset the test delivery collection
392
+ def clear_deliveries
393
+ @deliveries = []
394
+ end
395
+
396
+ # Register callback for successful delivery
397
+ def after_deliver(method_name = nil, &block)
398
+ self.after_deliver_callback = block_given? ? block : method_name
399
+ end
400
+
401
+ # Register callback for when retries are exhausted
402
+ def after_retries_exhausted(method_name = nil, &block)
403
+ self.after_retries_exhausted_callback = block_given? ? block : method_name
404
+ end
405
+ end
406
+ end
407
+
408
+ # Delivery messenger for ActionMailer-like API
409
+ class DeliveryMessenger
410
+ def initialize(webhook)
411
+ @webhook = webhook
412
+ end
413
+
414
+ def deliver_now
415
+ if @webhook.respond_to?(:perform_deliveries) && !@webhook.perform_deliveries
416
+ # Skip delivery
417
+ nil
418
+ elsif @webhook.class.delivery_method == :test
419
+ # Test delivery
420
+ ActionWebhook::Base.deliveries << @webhook
421
+ else
422
+ # Normal delivery
423
+ @webhook.deliver_now
424
+ end
425
+ end
426
+
427
+ def deliver_later(options = {})
428
+ @webhook.deliver_later(options)
429
+ end
430
+ end
431
+ end
@@ -0,0 +1,30 @@
1
+ module ActionWebhook
2
+ # Job responsible for delivering webhooks in the background
3
+ class DeliveryJob < ActiveJob::Base
4
+ queue_as { ActionWebhook::Base.deliver_later_queue_name || :webhooks }
5
+
6
+ # Performs the webhook delivery with the specified delivery method
7
+ #
8
+ # @param delivery_method [String] The delivery method to call (e.g., "deliver_now")
9
+ # @param serialized_webhook [Hash] The serialized webhook data
10
+ def perform(delivery_method, serialized_webhook)
11
+ # Reconstruct the webhook from serialized data
12
+ webhook_class = serialized_webhook['webhook_class'].constantize
13
+ webhook = webhook_class.new
14
+ webhook.deserialize(serialized_webhook)
15
+
16
+ # Invoke the specified delivery method
17
+ webhook.send(delivery_method)
18
+ end
19
+
20
+ # Handles serialization failures by logging errors
21
+ rescue_from StandardError do |exception|
22
+ # Log the error
23
+ Rails.logger.error("ActionWebhook delivery failed: #{exception.message}")
24
+ Rails.logger.error(exception.backtrace.join("\n"))
25
+
26
+ # Re-raise the exception if in development or test
27
+ raise exception if Rails.env.development? || Rails.env.test?
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionWebhook
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,10 @@
1
+ require "active_job"
2
+ require "httparty"
3
+
4
+ require "action_webhook/version"
5
+ require "action_webhook/delivery_job"
6
+ require "action_webhook/base"
7
+
8
+ module ActionWebhook
9
+ class Error < StandardError; end
10
+ end
metadata ADDED
@@ -0,0 +1,90 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: action_webhook
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Vinay Uttam Vemparala
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2025-04-25 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '7'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '7'
26
+ - !ruby/object:Gem::Dependency
27
+ name: httparty
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: 0.18.1
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: 0.18.1
40
+ - !ruby/object:Gem::Dependency
41
+ name: yard
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ description: A Rails library for triggering webhooks. Inspired by ActionMailer from
55
+ Rails
56
+ email:
57
+ - 15381417+vinayuttam@users.noreply.github.com
58
+ executables: []
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - lib/action_webhook.rb
63
+ - lib/action_webhook/base.rb
64
+ - lib/action_webhook/delivery_job.rb
65
+ - lib/action_webhook/version.rb
66
+ homepage: https://github.com/vinayuttam/action_webhook.git
67
+ licenses:
68
+ - MIT
69
+ metadata:
70
+ homepage_uri: https://github.com/vinayuttam/action_webhook.git
71
+ source_code_uri: https://github.com/vinayuttam/action_webhook.git
72
+ changelog_uri: https://github.com/vinayuttam/action_webhook.git/blob/main/CHANGELOG.md
73
+ rdoc_options: []
74
+ require_paths:
75
+ - lib
76
+ required_ruby_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: 3.1.0
81
+ required_rubygems_version: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ version: '0'
86
+ requirements: []
87
+ rubygems_version: 3.6.2
88
+ specification_version: 4
89
+ summary: A gem for triggering webhooks similar to trigger emails on Rails
90
+ test_files: []