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
data/Rakefile ADDED
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env rake
2
+
3
+ def initialize_rake_environment
4
+ return unless require_relative "config/environment"
5
+ rescue LoadError
6
+ puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
7
+ end
8
+
9
+ Bundler::GemHelper.install_tasks
10
+
11
+ APP_RAKEFILE = File.expand_path("../spec/dummy/Rakefile", __FILE__)
12
+ load 'rails/tasks/engine.rake'
13
+
14
+ task default: :spec
15
+
16
+ require "rspec/core/rake_task"
17
+ RSpec::Core::RakeTask.new(:spec)
18
+
19
+ require "rubocop/rake_task"
20
+ RuboCop::RakeTask.new do |task|
21
+ task.requires << "rubocop-performance"
22
+ task.requires << "rubocop-rspec"
23
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path("lib", __dir__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require "active_webhook/version"
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = "active_webhook"
9
+ spec.version = ActiveWebhook::VERSION
10
+ spec.authors = ["Jay Crouch"]
11
+ spec.email = ["i.jaycrouch@gmail.com"]
12
+
13
+ spec.summary = "Simple, efficient, and extensible webhooks for Ruby."
14
+ spec.description = "Simple, efficient, and extensible webhooks for Ruby, including: Rate Limits, Cryptographic " \
15
+ "Signatures, Asynchronous Delivery, Buffered Delivery, Versioning."
16
+ spec.homepage = "https://github.com/amazing-jay/active_webhook"
17
+ spec.license = "MIT"
18
+
19
+ # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
20
+ # to allow pushing to a single host or delete this section to allow pushing to any host.
21
+ if spec.respond_to?(:metadata)
22
+ # spec.metadata["allowed_push_host"] = "TODO: Set to 'http://mygemserver.com'"
23
+
24
+ spec.metadata["homepage_uri"] = spec.homepage
25
+ spec.metadata["source_code_uri"] = "https://github.com/amazing-jay/active_webhook"
26
+ spec.metadata["changelog_uri"] = "https://github.com/amazing-jay/active_webhook/master/tree/CHANGELOG.md."
27
+ else
28
+ raise "RubyGems 2.0 or newer is required to protect against public gem pushes."
29
+ end
30
+
31
+ # Specify which files should be added to the gem when it is released.
32
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
33
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
34
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features|bin)/}) }
35
+ end
36
+
37
+ spec.bindir = "exe"
38
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
39
+ spec.require_paths = ["lib"]
40
+ spec.required_ruby_version = ">= 2.5"
41
+
42
+ spec.add_dependency "activerecord", ">= 5.0.0"
43
+ spec.add_dependency "memoist"
44
+
45
+ spec.add_development_dependency "awesome_print", "~> 1.9.2"
46
+ spec.add_development_dependency "bundler", "~> 1.17"
47
+ spec.add_development_dependency "database_cleaner", "~> 2.0.1"
48
+ spec.add_development_dependency "delayed_job_active_record", "~> 4.1.6"
49
+ spec.add_development_dependency "dotenv", "~> 2.5"
50
+ spec.add_development_dependency "factory_bot", "~> 6.2.0"
51
+ spec.add_development_dependency "faker", "~> 2.18"
52
+ spec.add_development_dependency "faraday", "~> 1.4.2"
53
+ spec.add_development_dependency "listen", "~> 3.5.1"
54
+ spec.add_development_dependency "pry-byebug", "~> 3.9"
55
+
56
+ # must come before those below
57
+ if ENV['TEST_RAILS_VERSION'].nil?
58
+ spec.add_development_dependency 'rails', '~> 6.1.3.2'
59
+ else
60
+ spec.add_development_dependency 'rails', ENV['TEST_RAILS_VERSION'].to_s
61
+ end
62
+
63
+ spec.add_development_dependency "rake", "~> 10.0"
64
+ spec.add_development_dependency "rspec", "~> 3.0"
65
+ spec.add_development_dependency "rspec-rails", "~> 5.0.1"
66
+ spec.add_development_dependency "rubocop", "~> 0.60"
67
+ spec.add_development_dependency "rubocop-performance", "~> 1.5"
68
+ spec.add_development_dependency "rubocop-rspec", "~> 1.37"
69
+ spec.add_development_dependency "codecov", "~> 0.5.2"
70
+ spec.add_development_dependency "simplecov", "~> 0.16"
71
+ spec.add_development_dependency "sqlite3", "~> 1.4.2"
72
+ spec.add_development_dependency "sidekiq", "~> 6.2.1"
73
+ spec.add_development_dependency "rspec-sidekiq", "~> 3.1.0"
74
+ spec.add_development_dependency "webmock", "~> 3.13"
75
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dotenv/load"
4
+
5
+ ENV["RACK_ENV"] ||= ENV["RAILS_ENV"] ||= "development"
6
+
7
+ require "bundler/setup"
8
+
9
+ if RUBY_ENGINE == "jruby"
10
+ # Workaround for issue in I18n/JRuby combo.
11
+ # See https://github.com/jruby/jruby/issues/6547 and
12
+ # https://github.com/ruby-i18n/i18n/issues/555
13
+ require "i18n/backend"
14
+ require "i18n/backend/simple"
15
+ end
16
+
17
+ environments = [ENV["RAILS_ENV"].to_sym]
18
+ environments << :development if environments.last == :test
19
+
20
+ Bundler.require(*environments)
21
+
22
+ SimpleCov.start if ENV['RAILS_ENV'].to_s == "test"
23
+ SimpleCov.formatter = SimpleCov::Formatter::Codecov if ENV["CI"] == "true"
24
+
25
+ require "active_webhook"
26
+
27
+ db_config = File.read([__dir__.delete_suffix('/config'), "spec/dummy/config/database.yml"].join("/"))
28
+ db_config = ERB.new(db_config).result
29
+ db_config = YAML.safe_load(db_config, [], [], true)
30
+ DB_CONFIG = db_config[ENV["RAILS_ENV"]]
31
+ ActiveRecord::Base.establish_connection(DB_CONFIG)
32
+
33
+ FactoryBot.find_definitions if %w(test development).include? ENV["RAILS_ENV"]
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "memoist"
5
+
6
+ require "active_webhook/adapter"
7
+ require "active_webhook/delivery/base_adapter"
8
+ require "active_webhook/formatting/base_adapter"
9
+ require "active_webhook/queueing/base_adapter"
10
+ require "active_webhook/verification/base_adapter"
11
+
12
+ require "active_webhook/models/error_log_additions"
13
+ require "active_webhook/models/subscription_additions"
14
+ require "active_webhook/models/topic_additions"
15
+
16
+ require "active_webhook/callbacks"
17
+ require "active_webhook/hook"
18
+ require "active_webhook/error_log"
19
+ require "active_webhook/logger"
20
+ require "active_webhook/subscription"
21
+ require "active_webhook/topic"
22
+ require "active_webhook/version"
23
+
24
+ module ActiveWebhook
25
+ class InvalidAdapterError < StandardError; end
26
+
27
+ IDENTIFIER = "Active Webhook v#{VERSION}"
28
+
29
+ # IDENTIFIER must be defined first
30
+ require "active_webhook/configuration"
31
+
32
+ class << self
33
+ attr_writer :enabled
34
+
35
+ def configuration
36
+ @configuration ||= Configuration.new
37
+ end
38
+
39
+ def configure
40
+ yield(configuration)
41
+
42
+ configuration.after_configure
43
+ end
44
+
45
+ def logger
46
+ defined?(Rails) ? Rails.logger : (@logger ||= Logger.new($stdout))
47
+ end
48
+
49
+ def subscription_model
50
+ configuration.models.subscription
51
+ end
52
+
53
+ def topic_model
54
+ configuration.models.topic
55
+ end
56
+
57
+ def origin
58
+ return @@origin if defined? @@origin
59
+
60
+ @@origin = (Rails.application.config.action_mailer.default_url_options[:host] if defined?(Rails))
61
+ rescue StandardError
62
+ @@origin = ""
63
+ end
64
+
65
+ # TODO: change the next 4 methods to use memoized thread safe class var rather than configuration.enabled
66
+ def enabled?
67
+ configuration.enabled
68
+ end
69
+
70
+ def disabled?
71
+ !enabled?
72
+ end
73
+
74
+ def enable
75
+ state = enabled?
76
+ configuration.enabled = true
77
+ value = yield
78
+ ensure
79
+ configuration.enabled = state
80
+ value
81
+ end
82
+
83
+ def disable
84
+ state = enabled?
85
+ configuration.enabled = false
86
+ value = yield
87
+ ensure
88
+ configuration.enabled = state
89
+ value
90
+ end
91
+
92
+ Configuration::ADAPTERS.each do |type|
93
+ define_method "#{type}_adapter" do
94
+ fetch_adapter type, configuration.send(type).send("adapter")
95
+ end
96
+ end
97
+
98
+ def trigger(key:, version: nil, **context)
99
+ queueing_adapter.call(key: key, version: version, **context) if enabled?
100
+ true
101
+ end
102
+
103
+ protected
104
+
105
+ def fetch_adapter(type, adapter)
106
+ if adapter.is_a?(Symbol) || adapter.is_a?(String)
107
+ adapter = begin
108
+ @adapters ||= {}
109
+ @adapters[type] ||= {}
110
+ @adapters[type][adapter.to_sym] = begin
111
+ path = "active_webhook/#{type}/#{adapter}_adapter"
112
+ require path
113
+ const_name = path.camelize
114
+ ["http", "sha", "hmac", "json", "url"].each { |acronym| const_name.gsub!(acronym.camelize, acronym.upcase) }
115
+ const_name.constantize
116
+ end
117
+ end
118
+ end
119
+
120
+ raise InvalidAdapterError unless adapter.respond_to?(:call)
121
+
122
+ adapter
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveWebhook
4
+ class Adapter
5
+ extend Memoist
6
+ # TODO: memoize everything in all adapters
7
+
8
+ class << self
9
+ def attributes
10
+ @attributes ||= if self == ActiveWebhook::Adapter
11
+ []
12
+ else
13
+ ancestors.each_with_object([]) do |ancestor, attrs|
14
+ break attrs += ancestor.attributes if ancestor != self && ancestor.respond_to?(:attributes)
15
+
16
+ attrs
17
+ end
18
+ end
19
+
20
+ # # byebug
21
+ # # puts ['start', self, @attributes].join(', ')
22
+ # @attributes ||= ancestors.each_with_object([]) do |ancestor, attrs|
23
+ # # break attrs if ancestor == ActiveWebhook::Adapter
24
+
25
+ # if ancestor != self
26
+ # if ancestor.respond_to?(:attributes)
27
+ # break attrs += ancestor.attributes
28
+ # end
29
+ # end
30
+ # attrs
31
+
32
+ # # puts ['searching', ancestor, attrs].join(', ')
33
+
34
+ # # byebug
35
+ # # break attrs if ancestor == ActiveWebhook::Adapter
36
+ # # break attrs if ancestor != self && ancestor.respond_to?(:attributes))
37
+ # # byebug
38
+ # # attrs
39
+ # end
40
+ # puts ['finished', self, @attributes].join(' ,')
41
+ # # byebug
42
+ # # @attributes ||= []
43
+ # @attributes
44
+ end
45
+
46
+ def attribute(*attrs)
47
+ # Module.new.tap do |m| # Using anonymous modules so that super can be used to extend accessor methods
48
+ # include m
49
+
50
+ attrs = attrs.map(&:to_sym)
51
+ (attrs - attributes).each do |attr_name|
52
+ attributes << attr_name.to_sym
53
+ # m.attr_accessor attr_name
54
+ attr_accessor attr_name
55
+ # end
56
+ end
57
+ end
58
+
59
+ # def inherited(subclass)
60
+ # byebug
61
+ # # super
62
+ # subclass.instance_variable_set(:@attributes, ancestors.each_with_object([]) do |ancestor, attrs|
63
+ # unless ancestor == self || !ancestor.respond_to?(:attributes)
64
+ # break attrs += ancestor.attributes
65
+ # end
66
+
67
+ # attrs
68
+ # end)
69
+ # # byebug
70
+ # # x = 1
71
+ # end
72
+
73
+ def call(*args, **kwargs, &block)
74
+ new(*args, **kwargs).call(&block)
75
+ end
76
+
77
+ def component_name
78
+ raise NotImplementedError, ".component_name must be implemented."
79
+ end
80
+
81
+ def configuration
82
+ ActiveWebhook.configuration
83
+ end
84
+
85
+ def component_configuration
86
+ configuration.send(component_name)
87
+ end
88
+ end
89
+
90
+ attribute :context
91
+
92
+ def initialize(**kwargs)
93
+ self.class.attributes.each do |attr_name|
94
+ send("#{attr_name}=", kwargs[attr_name]) unless attr_name == :context
95
+ end
96
+ self.context = kwargs #.symbolize_keys! #with_indifferent_access
97
+ end
98
+
99
+ def call
100
+ raise NotImplementedError, "#call must be implemented."
101
+ end
102
+
103
+ def attributes
104
+ self.class.attributes.each_with_object({}) do |attr_name, h|
105
+ h[attr_name] = send(attr_name)
106
+ end
107
+ end
108
+
109
+ def configuration
110
+ self.class.configuration
111
+ end
112
+
113
+ def component_configuration
114
+ self.class.component_configuration
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveWebhook
4
+ module Callbacks
5
+ class InvalidCallbackError < StandardError; end
6
+ extend ActiveSupport::Concern
7
+
8
+ SUPPORTED_CALLBACKS = %i(created updated deleted).freeze
9
+
10
+ class_methods do
11
+ def trigger_webhooks(version: nil, only: nil, except: [], **_options)
12
+ callbacks = if only.nil?
13
+ SUPPORTED_CALLBACKS
14
+ else
15
+ Array.wrap(only).map(&:to_sym)
16
+ end - Array.wrap(except).map(&:to_sym)
17
+
18
+ callbacks.each do |callback|
19
+ unless SUPPORTED_CALLBACKS.include? callback
20
+ raise InvalidCallbackError, "Invalid callback: #{callback}. Must be one of #{SUPPORTED_CALLBACKS}."
21
+ end
22
+ end
23
+
24
+ after_commit :trigger_created_webhook, on: :create if callbacks.include?(:created)
25
+
26
+ after_commit :trigger_updated_webhook, on: :update if callbacks.include?(:updated)
27
+
28
+ after_commit :trigger_deleted_webhook, on: :destroy if callbacks.include?(:deleted)
29
+ end
30
+ end
31
+
32
+ def trigger_created_webhook
33
+ trigger_webhook(:created)
34
+ end
35
+
36
+ def trigger_updated_webhook
37
+ trigger_webhook(:updated) unless previous_changes.empty?
38
+ end
39
+
40
+ def trigger_deleted_webhook
41
+ trigger_webhook(:deleted)
42
+ end
43
+
44
+ def trigger_webhook(key, version: nil, type: 'resource', **context)
45
+ key = [self.class.name.underscore, key].join("/") unless key.is_a?(String)
46
+ context[:resource_id] ||= id
47
+ context[:resource_type] ||= self.class.name
48
+
49
+ ActiveWebhook.trigger(key: key, version: version, type: type, **context)
50
+ end
51
+ end
52
+ ActiveRecord::Base.include ActiveWebhook::Callbacks
53
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveWebhook
4
+ class Configuration
5
+ class InvalidOptionError < StandardError
6
+ def initialize(option, value, values)
7
+ @option = option
8
+ @values = values
9
+ msg = "Invalid option for #{option}: #{value}. Must be one of #{values}."
10
+ super(msg)
11
+ end
12
+ end
13
+
14
+ module Base
15
+ extend ActiveSupport::Concern
16
+
17
+ def initialize
18
+ (self.class.instance_variable_get(:@components) || []).each do |component_name|
19
+ component = "#{self.class.name.deconstantize}::#{component_name.to_s.camelize}::Configuration"
20
+ component = component.constantize.new
21
+ instance_variable_set "@#{component_name}", component
22
+ end
23
+
24
+ (self.class.instance_variable_get(:@options) || []).each do |option, option_definition|
25
+ send "#{option}=", option_definition[:default]
26
+ end
27
+ end
28
+
29
+ class_methods do
30
+ protected
31
+
32
+ # TODO: consider changing so that all options accept a proc
33
+ # q: does the proc run every time the option is asked? seems inefficent
34
+ def define_option(option, values: [], default: nil, allow_nil: false, allow_proc: false, prefixes: nil)
35
+ attr_reader option
36
+
37
+ default = values&.first if default.nil? && !allow_nil
38
+ prefixes ||= name.deconstantize.underscore.delete_prefix("active_webhook").split("/")
39
+ prefixes.shift if prefixes.first.blank?
40
+
41
+ @options ||= {}
42
+ @options[option] = {
43
+ values: values,
44
+ default: default,
45
+ allow_proc: allow_proc,
46
+ prefixes: prefixes
47
+ }
48
+
49
+ const_set option.to_s.pluralize.upcase, values
50
+
51
+ define_method "#{option}=" do |value|
52
+ unless (allow_proc && value.respond_to?(:call)) ||
53
+ (allow_nil && value.nil?) ||
54
+ values.empty? ||
55
+ values.include?(value)
56
+ raise Configuration::InvalidOptionError.new (prefixes + [option]).compact.join("."), value,
57
+ values
58
+ end
59
+
60
+ instance_variable_set "@#{option}", value
61
+ end
62
+ end
63
+
64
+ def define_component(component_name)
65
+ @components ||= []
66
+ @components << component_name
67
+ require "#{name.deconstantize.to_s.underscore}/#{component_name}/configuration"
68
+ attr_reader component_name
69
+ end
70
+ end
71
+ end
72
+
73
+ include Base
74
+
75
+ define_component :models
76
+ ADAPTERS = %i[delivery formatting queueing verification].freeze
77
+ ADAPTERS.each { |component| define_component component }
78
+
79
+ define_option :origin
80
+ define_option :enabled, values: [true, false]
81
+
82
+ def origin=(value)
83
+ if (@origin = value).nil?
84
+ ActiveWebhook.remove_class_variable(:@@origin) if ActiveWebhook.class_variable_defined?(:@@origin)
85
+ else
86
+ ActiveWebhook.class_variable_set(:@@origin, value)
87
+ end
88
+ end
89
+
90
+ def after_configure
91
+ # reset logger,
92
+ # ActiveWebhook.class_variable_set(:@logger, nil)
93
+ # cause all adapter files specified to be loaded
94
+ ADAPTERS.each { |type| ActiveWebhook.send "#{type}_adapter" }
95
+
96
+ # (re)set relationships for all models
97
+ models.error_log.belongs_to :subscription, class_name: models.subscription.name, foreign_key: :subscription_id
98
+ models.topic.has_many :subscriptions, class_name: models.subscription.name, foreign_key: :topic_id
99
+ models.subscription.belongs_to :topic, class_name: models.topic.name, foreign_key: :topic_id
100
+ models.subscription.has_many :error_logs, class_name: models.error_log.name, foreign_key: :subscription_id
101
+
102
+ self
103
+ end
104
+ end
105
+ end