action_hooks 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 064ad39bd578ac07aaf40f9bbc3551a7f8134a8543666533c7346782ec38a7d2
4
+ data.tar.gz: f1f6e53ed1cd91f80ebbea439c765aa7a7ec9cae4a7ff315f3230b51fb92a388
5
+ SHA512:
6
+ metadata.gz: 29b792245c0630a7542e2189baaf89724090400baed65d26931e7d3fd513bf6f38092868ea9e0e7010b0940dcaa23058367ecde6387394c25c8519aa4990cb59
7
+ data.tar.gz: b166f6b4be57655cbab8449eba6ed16d9dfe54f8c0eb2449a6362ff24492dc4c7770391fc07da69c832b008e0d477f7b3b8475e1958daf2ebc287dfbd611a557
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-03-01
4
+
5
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Alexey Poimtsev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,119 @@
1
+ # ActionHooks
2
+
3
+ ActionHooks is a Ruby on Rails engine designed to securely handle incoming webhooks. It standardizes the process of receiving webhooks from various third-party services (like Stripe, GitHub, etc.) by:
4
+
5
+ 1. **Persisting Webhooks:** Saving all incoming requests to the database (`webhook_requests` table) with their payload, source, and processing state before any business logic is executed.
6
+ 2. **Security & Verification:** Verifying the authenticity of the webhook via signature validation logic and optionally restricting access by IP address.
7
+ 3. **Asynchronous Processing:** Automatically dispatching the saved webhook to a configured background worker (`ActiveJob`) for asynchronous processing.
8
+
9
+ ## Installation
10
+
11
+ Add this line to your application's Gemfile:
12
+
13
+ ```ruby
14
+ gem "action_hooks"
15
+ ```
16
+
17
+ And then execute:
18
+
19
+ ```bash
20
+ $ bundle install
21
+ ```
22
+
23
+ After installing the gem, you need to run the installation generator. This will create the necessary database migration for the `webhook_requests` table and an initializer file.
24
+
25
+ ```bash
26
+ $ rails generate action_hooks:install
27
+ ```
28
+
29
+ Run the database migrations:
30
+
31
+ ```bash
32
+ $ rails db:migrate
33
+ ```
34
+
35
+ ## Usage
36
+
37
+ ### 1. Configuration
38
+
39
+ Configure your webhook sources in the generated initializer (`config/initializers/action_hooks.rb`). Each source represents a third-party service sending webhooks to your application.
40
+
41
+ ```ruby
42
+ # config/initializers/action_hooks.rb
43
+ ActionHooks.configure do |config|
44
+ config.add_source(:stripe) do |source|
45
+ # The ActiveJob worker class that will process the webhook
46
+ source.worker = "StripeWebhookWorker"
47
+
48
+ # Lambda to verify the signature of the incoming request
49
+ source.verify_signature = ->(request) do
50
+ payload = request.body.read
51
+ sig_header = request.env['HTTP_STRIPE_SIGNATURE']
52
+ # Example using Stripe's library:
53
+ # Stripe::Webhook::Signature.verify_header(payload, sig_header, ENV['STRIPE_WEBHOOK_SECRET'])
54
+ true
55
+ end
56
+
57
+ # Optional: Restrict incoming requests to specific IP addresses
58
+ # source.allowed_ips = ["127.0.0.1", "10.0.0.1"]
59
+ end
60
+ end
61
+ ```
62
+
63
+ ### 2. Generating a Webhook Controller
64
+
65
+ To create an endpoint for a configured source, use the webhook generator. Pass the name of the source as an argument:
66
+
67
+ ```bash
68
+ $ rails generate action_hooks:webhook stripe
69
+ ```
70
+
71
+ This will:
72
+ 1. Create a controller at `app/controllers/stripe_webhooks_controller.rb`.
73
+ 2. Add a route to `config/routes.rb` (e.g., `post "webhooks/stripe", to: "stripe_webhooks#create"`).
74
+
75
+ The generated controller includes `ActionHooks::WebhookControllerBehavior`, which handles everything from skipping CSRF verification, verifying the IP and signature, saving the request to the database, and enqueueing your worker.
76
+
77
+ ### 3. Processing the Webhook
78
+
79
+ Create the worker class that you specified in your configuration. The worker will receive the ID of the `ActionHooks::WebhookRequest` record.
80
+
81
+ ```ruby
82
+ # app/jobs/stripe_webhook_worker.rb
83
+ class StripeWebhookWorker < ApplicationJob
84
+ queue_as :default
85
+
86
+ def perform(webhook_request_id)
87
+ webhook_request = ActionHooks::WebhookRequest.find(webhook_request_id)
88
+
89
+ # Access the parsed JSON payload
90
+ payload = webhook_request.payload
91
+
92
+ # Process the payload...
93
+ if payload['type'] == 'payment_intent.succeeded'
94
+ # Do something
95
+ end
96
+
97
+ # Update the state of the webhook request when done
98
+ webhook_request.processed!
99
+ rescue => e
100
+ # Mark as failed if something goes wrong
101
+ webhook_request.failed!
102
+ raise e
103
+ end
104
+ end
105
+ ```
106
+
107
+ ## Development
108
+
109
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests.
110
+
111
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
112
+
113
+ ## Contributing
114
+
115
+ Bug reports and pull requests are welcome on GitHub at https://github.com/alec-c4/action_hooks.
116
+
117
+ ## License
118
+
119
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionHooks
4
+ class SourceNotDefinedError < StandardError; end
5
+
6
+ class Source
7
+ attr_accessor :name, :worker, :verify_signature, :allowed_ips
8
+
9
+ def initialize(name)
10
+ @name = name
11
+ @verify_signature = ->(_request) { true }
12
+ @allowed_ips = []
13
+ end
14
+ end
15
+
16
+ class Configuration
17
+ def initialize
18
+ @sources = {}
19
+ end
20
+
21
+ def add_source(name)
22
+ source = Source.new(name)
23
+ yield(source) if block_given?
24
+ @sources[name.to_sym] = source
25
+ end
26
+
27
+ def source(name)
28
+ @sources.fetch(name.to_sym)
29
+ rescue KeyError
30
+ raise SourceNotDefinedError, "Source :#{name} is not defined in ActionHooks configuration"
31
+ end
32
+ end
33
+
34
+ class << self
35
+ def configuration
36
+ @configuration ||= Configuration.new
37
+ end
38
+
39
+ def configure
40
+ yield(configuration)
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+
5
+ module ActionHooks
6
+ class Engine < ::Rails::Engine
7
+ isolate_namespace ActionHooks
8
+ end
9
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionHooks
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionHooks
4
+ module WebhookControllerBehavior
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ # Skip CSRF since webhooks are typically APIs
9
+ skip_before_action :verify_authenticity_token, raise: false if respond_to?(:skip_before_action)
10
+
11
+ before_action :verify_webhook_ip!
12
+ before_action :verify_webhook_signature!
13
+ end
14
+
15
+ def create
16
+ payload = parse_webhook_payload
17
+
18
+ webhook_request = ActionHooks::WebhookRequest.create!(
19
+ source: webhook_source_name.to_s,
20
+ payload: payload,
21
+ state: :pending
22
+ )
23
+
24
+ worker_class = webhook_source_config.worker
25
+ worker_class&.constantize&.perform_later(webhook_request.id)
26
+
27
+ head :ok
28
+ end
29
+
30
+ private
31
+
32
+ def webhook_source_name
33
+ # To be overridden or inferred by the controller
34
+ self.class.name.sub(/WebhooksController$/, "").underscore
35
+ end
36
+
37
+ def webhook_source_config
38
+ ActionHooks.configuration.source(webhook_source_name)
39
+ end
40
+
41
+ def verify_webhook_ip!
42
+ allowed_ips = webhook_source_config.allowed_ips
43
+ return if allowed_ips.empty?
44
+
45
+ unless allowed_ips.include?(request.remote_ip)
46
+ head :forbidden
47
+ end
48
+ end
49
+
50
+ def verify_webhook_signature!
51
+ unless webhook_source_config.verify_signature.call(request)
52
+ head :unauthorized
53
+ end
54
+ end
55
+
56
+ def parse_webhook_payload
57
+ if request.content_type == "application/json"
58
+ JSON.parse(request.body.read).tap do
59
+ request.body.rewind
60
+ end
61
+ else
62
+ request.parameters.except(:controller, :action).to_unsafe_h
63
+ end
64
+ rescue JSON::ParserError
65
+ {}
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "action_hooks/version"
4
+ require_relative "action_hooks/configuration"
5
+
6
+ module ActionHooks
7
+ class Error < StandardError; end
8
+ end
9
+ require "action_hooks/engine"
10
+ require "action_hooks/webhook_controller_behavior"
@@ -0,0 +1,26 @@
1
+ require "rails/generators"
2
+ require "rails/generators/active_record"
3
+
4
+ module ActionHooks
5
+ module Generators
6
+ class InstallGenerator < ::Rails::Generators::Base
7
+ include ::Rails::Generators::Migration
8
+
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ desc "Installs ActionHooks migration and initializer"
12
+
13
+ def self.next_migration_number(dirname)
14
+ ::ActiveRecord::Generators::Base.next_migration_number(dirname)
15
+ end
16
+
17
+ def create_migration_file
18
+ migration_template "create_webhook_requests.rb.erb", "db/migrate/create_webhook_requests.rb"
19
+ end
20
+
21
+ def create_initializer_file
22
+ template "action_hooks.rb", "config/initializers/action_hooks.rb"
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ ActionHooks.configure do |config|
4
+ # Example Configuration for Stripe
5
+ # config.add_source(:stripe) do |source|
6
+ # source.worker = "StripeWebhookWorker" # Ensure you have this ActiveJob defined
7
+ #
8
+ # # Verify signature logic (returns a boolean)
9
+ # source.verify_signature = ->(request) do
10
+ # # Example:
11
+ # # payload = request.body.read
12
+ # # sig_header = request.env['HTTP_STRIPE_SIGNATURE']
13
+ # # Stripe::Webhook::Signature.verify_header(payload, sig_header, 'whsec_...', tolerance: Stripe::Webhook::DEFAULT_TOLERANCE)
14
+ # true
15
+ # end
16
+ #
17
+ # # Optional: IP allowlist
18
+ # # source.allowed_ips = ["127.0.0.1", "10.0.0.1"]
19
+ # end
20
+ end
@@ -0,0 +1,14 @@
1
+ class CreateWebhookRequests < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :webhook_requests, id: :uuid do |t|
4
+ t.jsonb :payload, default: {}, null: false
5
+ t.string :source, null: false
6
+ t.integer :state, null: false, default: 0
7
+
8
+ t.timestamps
9
+ end
10
+
11
+ add_index :webhook_requests, :source
12
+ add_index :webhook_requests, :state
13
+ end
14
+ end
@@ -0,0 +1,9 @@
1
+ class <%= class_name %>WebhooksController < ApplicationController
2
+ include ActionHooks::WebhookControllerBehavior
3
+
4
+ private
5
+
6
+ def webhook_source_name
7
+ :<%= file_name %>
8
+ end
9
+ end
@@ -0,0 +1,18 @@
1
+ require "rails/generators"
2
+
3
+ module ActionHooks
4
+ module Generators
5
+ class WebhookGenerator < ::Rails::Generators::NamedBase
6
+ source_root File.expand_path("templates", __dir__)
7
+ desc "Creates a webhook controller for a given source and adds its route."
8
+
9
+ def create_controller_file
10
+ template "controller.rb.erb", "app/controllers/#{file_name}_webhooks_controller.rb"
11
+ end
12
+
13
+ def add_route
14
+ route %(post "webhooks/#{file_name}", to: "#{file_name}_webhooks#create")
15
+ end
16
+ end
17
+ end
18
+ end
metadata ADDED
@@ -0,0 +1,156 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: action_hooks
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Alexey Poimtsev
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: bundler
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '2.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rake
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '13.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '13.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rspec
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '3.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '3.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rspec-rails
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: appraisal
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ - !ruby/object:Gem::Dependency
97
+ name: sqlite3
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ description: Save all incoming webhooks to the database, dispatch them to background
111
+ workers, and verify request signatures or IPs.
112
+ email:
113
+ - alexey.poimtsev@gmail.com
114
+ executables: []
115
+ extensions: []
116
+ extra_rdoc_files: []
117
+ files:
118
+ - CHANGELOG.md
119
+ - LICENSE.txt
120
+ - README.md
121
+ - lib/action_hooks.rb
122
+ - lib/action_hooks/configuration.rb
123
+ - lib/action_hooks/engine.rb
124
+ - lib/action_hooks/version.rb
125
+ - lib/action_hooks/webhook_controller_behavior.rb
126
+ - lib/generators/action_hooks/install/install_generator.rb
127
+ - lib/generators/action_hooks/install/templates/action_hooks.rb
128
+ - lib/generators/action_hooks/install/templates/create_webhook_requests.rb.erb
129
+ - lib/generators/action_hooks/webhook/templates/controller.rb.erb
130
+ - lib/generators/action_hooks/webhook/webhook_generator.rb
131
+ homepage: https://github.com/alec-c4/action_hooks
132
+ licenses:
133
+ - MIT
134
+ metadata:
135
+ homepage_uri: https://github.com/alec-c4/action_hooks
136
+ source_code_uri: https://github.com/alec-c4/action_hooks
137
+ changelog_uri: https://github.com/alec-c4/action_hooks/blob/main/CHANGELOG.md
138
+ rubygems_mfa_required: 'true'
139
+ rdoc_options: []
140
+ require_paths:
141
+ - lib
142
+ required_ruby_version: !ruby/object:Gem::Requirement
143
+ requirements:
144
+ - - ">="
145
+ - !ruby/object:Gem::Version
146
+ version: 3.2.0
147
+ required_rubygems_version: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - ">="
150
+ - !ruby/object:Gem::Version
151
+ version: '0'
152
+ requirements: []
153
+ rubygems_version: 4.0.7
154
+ specification_version: 4
155
+ summary: A Ruby gem for handling incoming webhooks securely.
156
+ test_files: []