action_webhook 0.1.0 → 0.1.1

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