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