active_webhook 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.
- checksums.yaml +7 -0
- data/.env.sample +1 -0
- data/.gitignore +63 -0
- data/.rspec +4 -0
- data/.rubocop.yml +86 -0
- data/.todo +48 -0
- data/.travis.yml +14 -0
- data/CHANGELOG.md +5 -0
- data/DEV_README.md +199 -0
- data/Gemfile +8 -0
- data/LICENSE.txt +21 -0
- data/README.md +483 -0
- data/Rakefile +23 -0
- data/active_webhook.gemspec +75 -0
- data/config/environment.rb +33 -0
- data/lib/active_webhook.rb +125 -0
- data/lib/active_webhook/adapter.rb +117 -0
- data/lib/active_webhook/callbacks.rb +53 -0
- data/lib/active_webhook/configuration.rb +105 -0
- data/lib/active_webhook/delivery/base_adapter.rb +96 -0
- data/lib/active_webhook/delivery/configuration.rb +16 -0
- data/lib/active_webhook/delivery/faraday_adapter.rb +19 -0
- data/lib/active_webhook/delivery/net_http_adapter.rb +28 -0
- data/lib/active_webhook/error_log.rb +7 -0
- data/lib/active_webhook/formatting/base_adapter.rb +109 -0
- data/lib/active_webhook/formatting/configuration.rb +18 -0
- data/lib/active_webhook/formatting/json_adapter.rb +19 -0
- data/lib/active_webhook/formatting/url_encoded_adapter.rb +28 -0
- data/lib/active_webhook/hook.rb +9 -0
- data/lib/active_webhook/logger.rb +21 -0
- data/lib/active_webhook/models/configuration.rb +18 -0
- data/lib/active_webhook/models/error_log_additions.rb +15 -0
- data/lib/active_webhook/models/subscription_additions.rb +72 -0
- data/lib/active_webhook/models/topic_additions.rb +70 -0
- data/lib/active_webhook/queueing/active_job_adapter.rb +43 -0
- data/lib/active_webhook/queueing/base_adapter.rb +67 -0
- data/lib/active_webhook/queueing/configuration.rb +15 -0
- data/lib/active_webhook/queueing/delayed_job_adapter.rb +28 -0
- data/lib/active_webhook/queueing/sidekiq_adapter.rb +43 -0
- data/lib/active_webhook/queueing/syncronous_adapter.rb +14 -0
- data/lib/active_webhook/subscription.rb +7 -0
- data/lib/active_webhook/topic.rb +7 -0
- data/lib/active_webhook/verification/base_adapter.rb +31 -0
- data/lib/active_webhook/verification/configuration.rb +13 -0
- data/lib/active_webhook/verification/hmac_sha256_adapter.rb +20 -0
- data/lib/active_webhook/verification/unsigned_adapter.rb +11 -0
- data/lib/active_webhook/version.rb +5 -0
- data/lib/generators/install_generator.rb +20 -0
- data/lib/generators/migrations_generator.rb +24 -0
- data/lib/generators/templates/20210618023338_create_active_webhook_tables.rb +31 -0
- data/lib/generators/templates/active_webhook_config.rb +87 -0
- metadata +447 -0
@@ -0,0 +1,96 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveWebhook
|
4
|
+
module Delivery
|
5
|
+
class BaseAdapter < Adapter
|
6
|
+
attribute :subscription, :hook, :max_errors_per_hour
|
7
|
+
attr_accessor :response
|
8
|
+
|
9
|
+
delegate :url, :headers, :body, to: :hook
|
10
|
+
|
11
|
+
def max_errors_per_hour
|
12
|
+
@max_errors_per_hour.nil? ? component_configuration.max_errors_per_hour : @max_errors_per_hour
|
13
|
+
end
|
14
|
+
|
15
|
+
def call
|
16
|
+
ensure_error_log_requirement_is_met!
|
17
|
+
|
18
|
+
wrap_with_log do
|
19
|
+
self.response = deliver!
|
20
|
+
|
21
|
+
case status_code
|
22
|
+
when 200
|
23
|
+
trace "Completed"
|
24
|
+
when 410
|
25
|
+
trace "Receieved HTTP response code [410] for"
|
26
|
+
subscription.destroy!
|
27
|
+
else
|
28
|
+
raise response.to_s
|
29
|
+
end
|
30
|
+
end
|
31
|
+
rescue StandardError => e
|
32
|
+
subscription.error_logs.create!
|
33
|
+
|
34
|
+
raise e # propogate error so queuing adapter has a chance to retry
|
35
|
+
end
|
36
|
+
|
37
|
+
def status_code
|
38
|
+
raise NotImplementedError, "#deliver! must be implemented."
|
39
|
+
end
|
40
|
+
|
41
|
+
def topic
|
42
|
+
subscription.topic
|
43
|
+
end
|
44
|
+
|
45
|
+
protected
|
46
|
+
|
47
|
+
def self.component_name
|
48
|
+
"delivery"
|
49
|
+
end
|
50
|
+
|
51
|
+
def deliver!
|
52
|
+
raise NotImplementedError, "#deliver! must be implemented."
|
53
|
+
end
|
54
|
+
|
55
|
+
def ensure_error_log_requirement_is_met!
|
56
|
+
if subscription.ensure_error_log_requirement_is_met! max_errors_per_hour
|
57
|
+
trace "Disabled"
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def wrap_with_log
|
62
|
+
return if ActiveWebhook.disabled?
|
63
|
+
|
64
|
+
trace("Skipped [subscription disabled]") and return if subscription.disabled?
|
65
|
+
trace("Skipped [topic disabled]") and return if topic.disabled?
|
66
|
+
trace "Initiated"
|
67
|
+
trace "Payload [#{hook.to_h.ai}] for", :debug if ActiveWebhook.logger.level == 0 # log_payloads
|
68
|
+
|
69
|
+
yield
|
70
|
+
|
71
|
+
trace "Completed"
|
72
|
+
|
73
|
+
true
|
74
|
+
rescue StandardError => e
|
75
|
+
trace "Failed to complete [#{e.message}]", :error
|
76
|
+
|
77
|
+
raise e # propogate error so queuing adapter has a chance to retry
|
78
|
+
end
|
79
|
+
|
80
|
+
def trace(msg, level = :info)
|
81
|
+
ActiveWebhook.logger.send(level, decorate_log_msg(msg))
|
82
|
+
|
83
|
+
true
|
84
|
+
end
|
85
|
+
|
86
|
+
def decorate_log_msg(msg)
|
87
|
+
[
|
88
|
+
msg,
|
89
|
+
"active webhook subscription #{subscription.id}",
|
90
|
+
"with(key: #{topic.key}, version: #{topic.version})",
|
91
|
+
"to url: #{hook.url} via #{self.class.name}"
|
92
|
+
].join(' ')
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveWebhook
|
4
|
+
module Delivery
|
5
|
+
class Configuration
|
6
|
+
include ActiveWebhook::Configuration::Base
|
7
|
+
|
8
|
+
define_option :adapter,
|
9
|
+
values: %i[net_http faraday],
|
10
|
+
allow_proc: true
|
11
|
+
|
12
|
+
define_option :max_errors_per_hour,
|
13
|
+
default: 100
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "faraday"
|
4
|
+
|
5
|
+
module ActiveWebhook
|
6
|
+
module Delivery
|
7
|
+
class FaradayAdapter < BaseAdapter
|
8
|
+
def status_code
|
9
|
+
response.status
|
10
|
+
end
|
11
|
+
|
12
|
+
protected
|
13
|
+
|
14
|
+
def deliver!
|
15
|
+
Faraday.post(url, body, headers)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "uri"
|
4
|
+
require "net/http"
|
5
|
+
|
6
|
+
module ActiveWebhook
|
7
|
+
module Delivery
|
8
|
+
class NetHTTPAdapter < BaseAdapter
|
9
|
+
def status_code
|
10
|
+
response.code.to_i
|
11
|
+
end
|
12
|
+
|
13
|
+
protected
|
14
|
+
|
15
|
+
def deliver!
|
16
|
+
uri = URI.parse(url.strip)
|
17
|
+
|
18
|
+
request = Net::HTTP::Post.new(uri.request_uri)
|
19
|
+
request.body = body
|
20
|
+
headers.each { |k, v| request[k] = v }
|
21
|
+
|
22
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
23
|
+
http.use_ssl = uri.scheme.casecmp("https").zero?
|
24
|
+
http.request(request)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,109 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveWebhook
|
4
|
+
module Formatting
|
5
|
+
class BaseAdapter < Adapter
|
6
|
+
attribute :subscription, :job_id, :type
|
7
|
+
|
8
|
+
def call
|
9
|
+
Hook.new url, headers, body
|
10
|
+
end
|
11
|
+
|
12
|
+
protected
|
13
|
+
|
14
|
+
def self.component_name
|
15
|
+
"formatting"
|
16
|
+
end
|
17
|
+
|
18
|
+
def url
|
19
|
+
subscription.callback_url
|
20
|
+
end
|
21
|
+
|
22
|
+
def headers
|
23
|
+
default_headers.stringify_keys.merge(custom_headers.transform_keys { |key| "#{prefix}-#{key}" })
|
24
|
+
end
|
25
|
+
|
26
|
+
def body
|
27
|
+
encoded_data
|
28
|
+
end
|
29
|
+
|
30
|
+
def encoded_data
|
31
|
+
raise NotImplementedError, "#encoded_data must be implemented."
|
32
|
+
end
|
33
|
+
|
34
|
+
def content_type
|
35
|
+
raise NotImplementedError, "#content_type must be implemented."
|
36
|
+
end
|
37
|
+
|
38
|
+
def default_headers
|
39
|
+
h = {
|
40
|
+
"Content-Type": content_type,
|
41
|
+
"User-Agent": component_configuration.user_agent
|
42
|
+
}
|
43
|
+
h['Origin'] = ActiveWebhook.origin.to_s if ActiveWebhook.origin.present?
|
44
|
+
h
|
45
|
+
end
|
46
|
+
|
47
|
+
def custom_headers
|
48
|
+
h = signature_headers.merge(
|
49
|
+
Time: time.to_s,
|
50
|
+
Topic: topic.key,
|
51
|
+
'Topic-Version': topic.version,
|
52
|
+
'Webhook-Type': type.presence || "event"
|
53
|
+
)
|
54
|
+
h['Webhook-Id'] = job_id if job_id.present?
|
55
|
+
h
|
56
|
+
end
|
57
|
+
|
58
|
+
def data
|
59
|
+
context[:data].presence || resource&.as_json || default_data
|
60
|
+
end
|
61
|
+
|
62
|
+
def default_data
|
63
|
+
result = { data: {} }
|
64
|
+
|
65
|
+
if resource_id || resource_type
|
66
|
+
result[:data] = {}
|
67
|
+
result[:data][:id] = resource_id if resource_id
|
68
|
+
result[:data][:type] = resource_type if resource_type
|
69
|
+
end
|
70
|
+
|
71
|
+
result
|
72
|
+
end
|
73
|
+
|
74
|
+
def resource_id
|
75
|
+
context[:resource_id]
|
76
|
+
end
|
77
|
+
|
78
|
+
def resource_type
|
79
|
+
context[:resource_type]
|
80
|
+
end
|
81
|
+
|
82
|
+
def resource
|
83
|
+
resource_type.constantize.find_by(id: resource_id) if type == "resource" && resource_id && resource_type
|
84
|
+
rescue StandardError
|
85
|
+
nil
|
86
|
+
end
|
87
|
+
|
88
|
+
def topic
|
89
|
+
subscription.topic
|
90
|
+
end
|
91
|
+
|
92
|
+
def prefix
|
93
|
+
@prefix ||= begin
|
94
|
+
x = ["X"]
|
95
|
+
x << component_configuration.custom_header_prefix
|
96
|
+
x.compact.join("-")
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
def time
|
101
|
+
context[:time] || Time.current
|
102
|
+
end
|
103
|
+
|
104
|
+
def signature_headers
|
105
|
+
ActiveWebhook.verification_adapter.call secret: subscription.shared_secret.to_s, data: body.to_s
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveWebhook
|
4
|
+
module Formatting
|
5
|
+
class Configuration
|
6
|
+
include ActiveWebhook::Configuration::Base
|
7
|
+
|
8
|
+
define_option :adapter,
|
9
|
+
values: %i[json url_encoded],
|
10
|
+
allow_proc: true
|
11
|
+
|
12
|
+
define_option :custom_header_prefix
|
13
|
+
|
14
|
+
define_option :user_agent,
|
15
|
+
default: ActiveWebhook::IDENTIFIER
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
|
5
|
+
module ActiveWebhook
|
6
|
+
module Formatting
|
7
|
+
class JSONAdapter < BaseAdapter
|
8
|
+
protected
|
9
|
+
|
10
|
+
def content_type
|
11
|
+
"application/json"
|
12
|
+
end
|
13
|
+
|
14
|
+
def encoded_data
|
15
|
+
data.to_json
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "addressable/uri"
|
4
|
+
|
5
|
+
module ActiveWebhook
|
6
|
+
module Formatting
|
7
|
+
class URLEncodedAdapter < BaseAdapter
|
8
|
+
protected
|
9
|
+
|
10
|
+
def self.compact(h)
|
11
|
+
h.delete_if { |k, v|
|
12
|
+
v = compact(v) if v.respond_to?(:each)
|
13
|
+
v.nil? || v.empty?
|
14
|
+
}
|
15
|
+
end
|
16
|
+
|
17
|
+
def content_type
|
18
|
+
"application/x-www-form-urlencoded"
|
19
|
+
end
|
20
|
+
|
21
|
+
def encoded_data
|
22
|
+
uri = Addressable::URI.new
|
23
|
+
uri.query_values = self.class.compact(data)
|
24
|
+
uri.query
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveWebhook
|
4
|
+
module Models
|
5
|
+
class Configuration
|
6
|
+
include ActiveWebhook::Configuration::Base
|
7
|
+
|
8
|
+
define_option :subscription,
|
9
|
+
default: ActiveWebhook::Subscription
|
10
|
+
|
11
|
+
define_option :topic,
|
12
|
+
default: ActiveWebhook::Topic
|
13
|
+
|
14
|
+
define_option :error_log,
|
15
|
+
default: ActiveWebhook::ErrorLog
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveWebhook
|
4
|
+
module Models
|
5
|
+
module ErrorLogAdditions
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
|
8
|
+
included do
|
9
|
+
self.table_name = "active_webhook_error_logs"
|
10
|
+
|
11
|
+
validates_presence_of :subscription
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveWebhook
|
4
|
+
module Models
|
5
|
+
module SubscriptionAdditions
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
|
8
|
+
included do
|
9
|
+
self.table_name = "active_webhook_subscriptions"
|
10
|
+
|
11
|
+
scope :enabled, -> { where(disabled_at: nil) }
|
12
|
+
|
13
|
+
validates_presence_of :topic
|
14
|
+
validates :callback_url, format: {
|
15
|
+
with: URI::DEFAULT_PARSER.make_regexp(['http', 'https']),
|
16
|
+
message: 'is not a valid URL'
|
17
|
+
}
|
18
|
+
|
19
|
+
before_save :set_disabled_reason
|
20
|
+
after_save :clean_error_log
|
21
|
+
end
|
22
|
+
|
23
|
+
def set_disabled_reason
|
24
|
+
self.disabled_reason = nil if self.disabled_at.nil?
|
25
|
+
end
|
26
|
+
|
27
|
+
def clean_error_log
|
28
|
+
error_logs.delete_all if previous_changes.key?(:disabled_at) && enabled?
|
29
|
+
end
|
30
|
+
|
31
|
+
def ensure_error_log_requirement_is_met! max_errors_per_hour
|
32
|
+
return false if disabled?
|
33
|
+
|
34
|
+
if max_errors_per_hour.present? && error_logs.where('created_at > ?', 1.hour.ago).count > max_errors_per_hour
|
35
|
+
disable! "Exceeded max_errors_per_hour of (#{max_errors_per_hour})"
|
36
|
+
return true
|
37
|
+
end
|
38
|
+
rescue StandardError
|
39
|
+
# intentionally squash errors so that we don't end up in a loop where queue adapter retries and locks table
|
40
|
+
false
|
41
|
+
end
|
42
|
+
|
43
|
+
def disable(reason = nil)
|
44
|
+
self.disabled_at = Time.current
|
45
|
+
self.disabled_reason = reason
|
46
|
+
end
|
47
|
+
|
48
|
+
def disable!(reason = nil)
|
49
|
+
disable reason
|
50
|
+
save!
|
51
|
+
end
|
52
|
+
|
53
|
+
def enable
|
54
|
+
self.disabled_at = nil
|
55
|
+
self.disabled_reason = nil
|
56
|
+
end
|
57
|
+
|
58
|
+
def enable!
|
59
|
+
enable
|
60
|
+
save!
|
61
|
+
end
|
62
|
+
|
63
|
+
def disabled?
|
64
|
+
!enabled?
|
65
|
+
end
|
66
|
+
|
67
|
+
def enabled?
|
68
|
+
disabled_at.nil?
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|