active_webhook 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/.env.sample +1 -0
  3. data/.gitignore +63 -0
  4. data/.rspec +4 -0
  5. data/.rubocop.yml +86 -0
  6. data/.todo +48 -0
  7. data/.travis.yml +14 -0
  8. data/CHANGELOG.md +5 -0
  9. data/DEV_README.md +199 -0
  10. data/Gemfile +8 -0
  11. data/LICENSE.txt +21 -0
  12. data/README.md +483 -0
  13. data/Rakefile +23 -0
  14. data/active_webhook.gemspec +75 -0
  15. data/config/environment.rb +33 -0
  16. data/lib/active_webhook.rb +125 -0
  17. data/lib/active_webhook/adapter.rb +117 -0
  18. data/lib/active_webhook/callbacks.rb +53 -0
  19. data/lib/active_webhook/configuration.rb +105 -0
  20. data/lib/active_webhook/delivery/base_adapter.rb +96 -0
  21. data/lib/active_webhook/delivery/configuration.rb +16 -0
  22. data/lib/active_webhook/delivery/faraday_adapter.rb +19 -0
  23. data/lib/active_webhook/delivery/net_http_adapter.rb +28 -0
  24. data/lib/active_webhook/error_log.rb +7 -0
  25. data/lib/active_webhook/formatting/base_adapter.rb +109 -0
  26. data/lib/active_webhook/formatting/configuration.rb +18 -0
  27. data/lib/active_webhook/formatting/json_adapter.rb +19 -0
  28. data/lib/active_webhook/formatting/url_encoded_adapter.rb +28 -0
  29. data/lib/active_webhook/hook.rb +9 -0
  30. data/lib/active_webhook/logger.rb +21 -0
  31. data/lib/active_webhook/models/configuration.rb +18 -0
  32. data/lib/active_webhook/models/error_log_additions.rb +15 -0
  33. data/lib/active_webhook/models/subscription_additions.rb +72 -0
  34. data/lib/active_webhook/models/topic_additions.rb +70 -0
  35. data/lib/active_webhook/queueing/active_job_adapter.rb +43 -0
  36. data/lib/active_webhook/queueing/base_adapter.rb +67 -0
  37. data/lib/active_webhook/queueing/configuration.rb +15 -0
  38. data/lib/active_webhook/queueing/delayed_job_adapter.rb +28 -0
  39. data/lib/active_webhook/queueing/sidekiq_adapter.rb +43 -0
  40. data/lib/active_webhook/queueing/syncronous_adapter.rb +14 -0
  41. data/lib/active_webhook/subscription.rb +7 -0
  42. data/lib/active_webhook/topic.rb +7 -0
  43. data/lib/active_webhook/verification/base_adapter.rb +31 -0
  44. data/lib/active_webhook/verification/configuration.rb +13 -0
  45. data/lib/active_webhook/verification/hmac_sha256_adapter.rb +20 -0
  46. data/lib/active_webhook/verification/unsigned_adapter.rb +11 -0
  47. data/lib/active_webhook/version.rb +5 -0
  48. data/lib/generators/install_generator.rb +20 -0
  49. data/lib/generators/migrations_generator.rb +24 -0
  50. data/lib/generators/templates/20210618023338_create_active_webhook_tables.rb +31 -0
  51. data/lib/generators/templates/active_webhook_config.rb +87 -0
  52. 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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveWebhook
4
+ class ErrorLog < ActiveRecord::Base
5
+ include ActiveWebhook::Models::ErrorLogAdditions
6
+ end
7
+ 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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveWebhook
4
+ Hook = Struct.new :url, :headers, :body do
5
+ def self.from_h(url: '', headers: {}, body: "")
6
+ new url, headers, body
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveWebhook
4
+ class Logger
5
+ def info(msg)
6
+ puts msg
7
+ end
8
+
9
+ def debug(msg)
10
+ puts msg
11
+ end
12
+
13
+ def error(msg)
14
+ puts msg
15
+ end
16
+
17
+ def warn(msg)
18
+ puts msg
19
+ end
20
+ end
21
+ 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