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.
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