active_webhook 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|