webhook_inbox 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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +83 -0
- data/CHANGELOG.md +25 -0
- data/README.md +315 -0
- data/Rakefile +10 -0
- data/app/controllers/webhook_inbox/application_controller.rb +28 -0
- data/app/controllers/webhook_inbox/dashboard_controller.rb +26 -0
- data/app/helpers/webhook_inbox/dashboard_helper.rb +17 -0
- data/app/jobs/webhook_inbox/process_job.rb +37 -0
- data/app/models/webhook_inbox/event.rb +38 -0
- data/app/views/layouts/webhook_inbox/application.html.erb +69 -0
- data/app/views/webhook_inbox/dashboard/index.html.erb +61 -0
- data/app/views/webhook_inbox/dashboard/show.html.erb +54 -0
- data/config/routes.rb +10 -0
- data/lib/generators/webhook_inbox/install/install_generator.rb +35 -0
- data/lib/generators/webhook_inbox/install/templates/README +33 -0
- data/lib/generators/webhook_inbox/install/templates/create_webhook_inbox_events.rb.erb +21 -0
- data/lib/generators/webhook_inbox/install/templates/initializer.rb +26 -0
- data/lib/webhook_inbox/configuration.rb +40 -0
- data/lib/webhook_inbox/engine.rb +19 -0
- data/lib/webhook_inbox/providers/base.rb +31 -0
- data/lib/webhook_inbox/providers/stripe.rb +49 -0
- data/lib/webhook_inbox/receiver.rb +72 -0
- data/lib/webhook_inbox/rspec/helpers.rb +49 -0
- data/lib/webhook_inbox/rspec.rb +7 -0
- data/lib/webhook_inbox/version.rb +5 -0
- data/lib/webhook_inbox.rb +36 -0
- data/sig/webhook_inbox.rbs +4 -0
- metadata +134 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<div class="wi-stats">
|
|
2
|
+
<% total = @status_counts.values.sum %>
|
|
3
|
+
<div class="wi-stat">
|
|
4
|
+
<div class="wi-stat-label">Total</div>
|
|
5
|
+
<div class="wi-stat-value"><%= total %></div>
|
|
6
|
+
</div>
|
|
7
|
+
<% %w[pending processing processed failed].each do |s| %>
|
|
8
|
+
<div class="wi-stat">
|
|
9
|
+
<div class="wi-stat-label"><%= s.capitalize %></div>
|
|
10
|
+
<div class="wi-stat-value"><%= @status_counts[s] || 0 %></div>
|
|
11
|
+
</div>
|
|
12
|
+
<% end %>
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
<%= form_with url: webhook_inbox.dashboard_path, method: :get, local: true, class: "wi-filters" do |f| %>
|
|
16
|
+
<label>Status</label>
|
|
17
|
+
<%= f.select :status,
|
|
18
|
+
[["All", ""], *%w[pending processing processed failed].map { |s| [s.capitalize, s] }],
|
|
19
|
+
{ selected: params[:status] },
|
|
20
|
+
{ onChange: "this.form.submit()" } %>
|
|
21
|
+
<label>Provider</label>
|
|
22
|
+
<%= f.select :provider,
|
|
23
|
+
[["All", ""], *@providers.map { |p| [p.capitalize, p] }],
|
|
24
|
+
{ selected: params[:provider] },
|
|
25
|
+
{ onChange: "this.form.submit()" } %>
|
|
26
|
+
<% end %>
|
|
27
|
+
|
|
28
|
+
<table>
|
|
29
|
+
<thead>
|
|
30
|
+
<tr>
|
|
31
|
+
<th>Status</th>
|
|
32
|
+
<th>Provider</th>
|
|
33
|
+
<th>Event Type</th>
|
|
34
|
+
<th>Event ID</th>
|
|
35
|
+
<th>Attempts</th>
|
|
36
|
+
<th>Received</th>
|
|
37
|
+
<th></th>
|
|
38
|
+
</tr>
|
|
39
|
+
</thead>
|
|
40
|
+
<tbody>
|
|
41
|
+
<% if @events.empty? %>
|
|
42
|
+
<tr>
|
|
43
|
+
<td colspan="7" style="text-align:center; color:#94a3b8; padding:32px;">No events yet.</td>
|
|
44
|
+
</tr>
|
|
45
|
+
<% else %>
|
|
46
|
+
<% @events.each do |event| %>
|
|
47
|
+
<tr>
|
|
48
|
+
<td><%= status_badge(event.status) %></td>
|
|
49
|
+
<td><span class="wi-mono"><%= event.provider %></span></td>
|
|
50
|
+
<td><span class="wi-mono"><%= event.event_type %></span></td>
|
|
51
|
+
<td><span class="wi-mono" title="<%= event.event_id %>"><%= event.event_id&.truncate(24) %></span></td>
|
|
52
|
+
<td><%= event.attempts %></td>
|
|
53
|
+
<td><span title="<%= event.created_at %>"><%= event.created_at.strftime("%b %d %H:%M:%S") %></span></td>
|
|
54
|
+
<td>
|
|
55
|
+
<%= link_to "View", webhook_inbox.event_path(event), class: "wi-btn wi-btn-back" %>
|
|
56
|
+
</td>
|
|
57
|
+
</tr>
|
|
58
|
+
<% end %>
|
|
59
|
+
<% end %>
|
|
60
|
+
</tbody>
|
|
61
|
+
</table>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
|
|
2
|
+
<%= link_to "← All Events", webhook_inbox.dashboard_path, class: "wi-btn wi-btn-back" %>
|
|
3
|
+
<%= button_to "Replay", webhook_inbox.replay_event_path(@event), method: :post, class: "wi-btn wi-btn-replay" %>
|
|
4
|
+
</div>
|
|
5
|
+
|
|
6
|
+
<div class="wi-detail-card">
|
|
7
|
+
<div class="wi-detail-row">
|
|
8
|
+
<span class="wi-detail-label">Status</span>
|
|
9
|
+
<%= status_badge(@event.status) %>
|
|
10
|
+
</div>
|
|
11
|
+
<div class="wi-detail-row">
|
|
12
|
+
<span class="wi-detail-label">Provider</span>
|
|
13
|
+
<span class="wi-mono"><%= @event.provider %></span>
|
|
14
|
+
</div>
|
|
15
|
+
<div class="wi-detail-row">
|
|
16
|
+
<span class="wi-detail-label">Event Type</span>
|
|
17
|
+
<span class="wi-mono"><%= @event.event_type %></span>
|
|
18
|
+
</div>
|
|
19
|
+
<div class="wi-detail-row">
|
|
20
|
+
<span class="wi-detail-label">Event ID</span>
|
|
21
|
+
<span class="wi-mono"><%= @event.event_id %></span>
|
|
22
|
+
</div>
|
|
23
|
+
<div class="wi-detail-row">
|
|
24
|
+
<span class="wi-detail-label">Attempts</span>
|
|
25
|
+
<span><%= @event.attempts %></span>
|
|
26
|
+
</div>
|
|
27
|
+
<div class="wi-detail-row">
|
|
28
|
+
<span class="wi-detail-label">Received</span>
|
|
29
|
+
<span><%= @event.created_at.strftime("%Y-%m-%d %H:%M:%S UTC") %></span>
|
|
30
|
+
</div>
|
|
31
|
+
<% if @event.processed_at %>
|
|
32
|
+
<div class="wi-detail-row">
|
|
33
|
+
<span class="wi-detail-label">Processed At</span>
|
|
34
|
+
<span><%= @event.processed_at.strftime("%Y-%m-%d %H:%M:%S UTC") %></span>
|
|
35
|
+
</div>
|
|
36
|
+
<% end %>
|
|
37
|
+
|
|
38
|
+
<% if @event.error_message.present? %>
|
|
39
|
+
<div style="margin-top:16px;">
|
|
40
|
+
<span class="wi-detail-label">Error</span>
|
|
41
|
+
<div class="wi-error"><%= @event.error_message %></div>
|
|
42
|
+
</div>
|
|
43
|
+
<% end %>
|
|
44
|
+
|
|
45
|
+
<div style="margin-top:20px;">
|
|
46
|
+
<div class="wi-detail-label" style="margin-bottom:8px;">Payload</div>
|
|
47
|
+
<% begin %>
|
|
48
|
+
<% pretty = JSON.pretty_generate(@event.parsed_payload) %>
|
|
49
|
+
<% rescue %>
|
|
50
|
+
<% pretty = @event.payload.to_s %>
|
|
51
|
+
<% end %>
|
|
52
|
+
<pre><%= pretty %></pre>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module WebhookInbox
|
|
7
|
+
module Generators
|
|
8
|
+
class InstallGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
desc "Creates a WebhookInbox migration and initializer in your application."
|
|
14
|
+
|
|
15
|
+
def create_migration
|
|
16
|
+
migration_template "create_webhook_inbox_events.rb.erb",
|
|
17
|
+
"db/migrate/create_webhook_inbox_events.rb"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def create_initializer
|
|
21
|
+
template "initializer.rb", "config/initializers/webhook_inbox.rb"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def show_readme
|
|
25
|
+
readme "README" if behavior == :invoke
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def migration_version
|
|
31
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
===============================================================================
|
|
2
|
+
|
|
3
|
+
WebhookInbox installed!
|
|
4
|
+
|
|
5
|
+
Next steps:
|
|
6
|
+
|
|
7
|
+
1. Run the migration:
|
|
8
|
+
rails db:migrate
|
|
9
|
+
|
|
10
|
+
2. Configure your handlers in config/initializers/webhook_inbox.rb
|
|
11
|
+
|
|
12
|
+
3. Add a controller for each provider:
|
|
13
|
+
|
|
14
|
+
class StripeWebhooksController < ApplicationController
|
|
15
|
+
include WebhookInbox::Receiver
|
|
16
|
+
receive_from :stripe, secret: -> { ENV["STRIPE_WEBHOOK_SECRET"] }
|
|
17
|
+
|
|
18
|
+
def create
|
|
19
|
+
receive_webhook!
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
4. Add the route:
|
|
24
|
+
|
|
25
|
+
# config/routes.rb
|
|
26
|
+
post "/webhooks/stripe", to: "stripe_webhooks#create"
|
|
27
|
+
|
|
28
|
+
# Optional dashboard:
|
|
29
|
+
mount WebhookInbox::Engine => "/webhook_inbox"
|
|
30
|
+
|
|
31
|
+
5. Set STRIPE_WEBHOOK_SECRET in your environment.
|
|
32
|
+
|
|
33
|
+
===============================================================================
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateWebhookInboxEvents < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
def change
|
|
5
|
+
create_table :webhook_inbox_events do |t|
|
|
6
|
+
t.string :provider, null: false
|
|
7
|
+
t.string :event_id, null: false
|
|
8
|
+
t.string :event_type
|
|
9
|
+
t.text :payload, null: false, default: "{}"
|
|
10
|
+
t.string :status, null: false, default: "pending"
|
|
11
|
+
t.integer :attempts, null: false, default: 0
|
|
12
|
+
t.text :error_message
|
|
13
|
+
t.datetime :processed_at
|
|
14
|
+
t.timestamps
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
add_index :webhook_inbox_events, [:provider, :event_id], unique: true
|
|
18
|
+
add_index :webhook_inbox_events, :status
|
|
19
|
+
add_index :webhook_inbox_events, :created_at
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
WebhookInbox.configure do |config|
|
|
4
|
+
# Register handlers for each provider + event type you want to handle.
|
|
5
|
+
# The block receives a WebhookInbox::Event object.
|
|
6
|
+
#
|
|
7
|
+
# config.on(:stripe, "customer.subscription.created") do |event|
|
|
8
|
+
# CreateSubscriptionJob.perform_later(event.parsed_payload)
|
|
9
|
+
# end
|
|
10
|
+
#
|
|
11
|
+
# config.on(:stripe, "invoice.payment_failed") do |event|
|
|
12
|
+
# NotifyPaymentFailedJob.perform_later(event.parsed_payload)
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# Catch-all for all Stripe events:
|
|
16
|
+
# config.on(:stripe, "*") do |event|
|
|
17
|
+
# Rails.logger.info "[WebhookInbox] Received #{event.event_type}"
|
|
18
|
+
# end
|
|
19
|
+
|
|
20
|
+
# Queue name for ProcessJob (default: "webhooks")
|
|
21
|
+
# config.queue_name = "webhooks"
|
|
22
|
+
|
|
23
|
+
# Dashboard auth lambda — return truthy to allow access.
|
|
24
|
+
# Required in production. Uncomment and customize:
|
|
25
|
+
# config.dashboard_auth = ->(controller) { controller.current_user&.admin? }
|
|
26
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WebhookInbox
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :queue_name, :dashboard_auth
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@queue_name = "webhooks"
|
|
9
|
+
@dashboard_auth = nil
|
|
10
|
+
@handlers = {}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Register a handler block for a given provider + event type.
|
|
14
|
+
# Use "*" as event_type to match any event from that provider.
|
|
15
|
+
#
|
|
16
|
+
# config.on(:stripe, "customer.subscription.created") { |event| ... }
|
|
17
|
+
# config.on(:stripe, "*") { |event| ... }
|
|
18
|
+
def on(provider, event_type, &block)
|
|
19
|
+
raise ArgumentError, "Handler block required" unless block
|
|
20
|
+
|
|
21
|
+
key = handler_key(provider, event_type)
|
|
22
|
+
@handlers[key] ||= []
|
|
23
|
+
@handlers[key] << block
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Returns all matching handler blocks for [provider, event_type].
|
|
27
|
+
# Includes exact matches and wildcard "*" handlers for the provider.
|
|
28
|
+
def handlers_for(provider, event_type)
|
|
29
|
+
exact = @handlers[handler_key(provider, event_type)] || []
|
|
30
|
+
wildcard = @handlers[handler_key(provider, "*")] || []
|
|
31
|
+
exact + wildcard
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def handler_key(provider, event_type)
|
|
37
|
+
"#{provider}:#{event_type}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails"
|
|
4
|
+
|
|
5
|
+
module WebhookInbox
|
|
6
|
+
class Engine < Rails::Engine
|
|
7
|
+
isolate_namespace WebhookInbox
|
|
8
|
+
|
|
9
|
+
initializer "webhook_inbox.initialize" do
|
|
10
|
+
WebhookInbox.configuration ||= WebhookInbox::Configuration.new
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
initializer "webhook_inbox.autoload_receiver" do
|
|
14
|
+
ActiveSupport.on_load(:action_controller) do
|
|
15
|
+
require "webhook_inbox/receiver"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WebhookInbox
|
|
4
|
+
module Providers
|
|
5
|
+
class Base
|
|
6
|
+
# Extract the unique event ID from the raw body and request.
|
|
7
|
+
# @param raw_body [String] the raw request body string
|
|
8
|
+
# @param request [ActionDispatch::Request]
|
|
9
|
+
# @return [String]
|
|
10
|
+
def event_id(raw_body, request)
|
|
11
|
+
raise NotImplementedError, "#{self.class}#event_id not implemented"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Extract the event type string (e.g. "customer.subscription.created").
|
|
15
|
+
# @param raw_body [String]
|
|
16
|
+
# @param request [ActionDispatch::Request]
|
|
17
|
+
# @return [String]
|
|
18
|
+
def event_type(raw_body, request)
|
|
19
|
+
raise NotImplementedError, "#{self.class}#event_type not implemented"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Verify the provider signature. Raise WebhookInbox::SignatureError on failure.
|
|
23
|
+
# @param raw_body [String]
|
|
24
|
+
# @param request [ActionDispatch::Request]
|
|
25
|
+
# @param secret [String]
|
|
26
|
+
def verify!(raw_body, request, secret:)
|
|
27
|
+
raise NotImplementedError, "#{self.class}#verify! not implemented"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "json"
|
|
5
|
+
require "active_support/security_utils"
|
|
6
|
+
|
|
7
|
+
module WebhookInbox
|
|
8
|
+
module Providers
|
|
9
|
+
class Stripe < Base
|
|
10
|
+
SIG_HEADER = "HTTP_STRIPE_SIGNATURE"
|
|
11
|
+
|
|
12
|
+
def event_id(raw_body, _request)
|
|
13
|
+
JSON.parse(raw_body)["id"]
|
|
14
|
+
rescue JSON::ParserError
|
|
15
|
+
nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def event_type(raw_body, _request)
|
|
19
|
+
JSON.parse(raw_body)["type"] || "unknown"
|
|
20
|
+
rescue JSON::ParserError
|
|
21
|
+
"unknown"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Stripe signature format: "t=<timestamp>,v1=<hmac>,v0=<hmac>"
|
|
25
|
+
# We verify v1 (HMAC-SHA256 of "timestamp.body" signed with the webhook secret).
|
|
26
|
+
# Raises WebhookInbox::SignatureError if invalid.
|
|
27
|
+
def verify!(raw_body, request, secret:)
|
|
28
|
+
sig_header = request.env[SIG_HEADER]
|
|
29
|
+
raise WebhookInbox::SignatureError, "Missing Stripe-Signature header" if sig_header.blank?
|
|
30
|
+
|
|
31
|
+
parts = sig_header.split(",").each_with_object({}) do |part, hash|
|
|
32
|
+
k, v = part.split("=", 2)
|
|
33
|
+
hash[k] = v if k && v
|
|
34
|
+
end
|
|
35
|
+
timestamp = parts["t"]
|
|
36
|
+
received = parts["v1"]
|
|
37
|
+
|
|
38
|
+
raise WebhookInbox::SignatureError, "Malformed Stripe-Signature header" unless timestamp && received
|
|
39
|
+
|
|
40
|
+
signed_payload = "#{timestamp}.#{raw_body}"
|
|
41
|
+
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, signed_payload)
|
|
42
|
+
|
|
43
|
+
return if ActiveSupport::SecurityUtils.secure_compare(expected, received)
|
|
44
|
+
|
|
45
|
+
raise WebhookInbox::SignatureError, "Stripe signature verification failed"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WebhookInbox
|
|
4
|
+
module Receiver
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
class_attribute :_webhook_provider, instance_accessor: false
|
|
9
|
+
class_attribute :_webhook_secret_resolver, instance_accessor: false
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class_methods do
|
|
13
|
+
# Declare the provider and secret for this controller.
|
|
14
|
+
#
|
|
15
|
+
# receive_from :stripe, secret: -> { ENV["STRIPE_WEBHOOK_SECRET"] }
|
|
16
|
+
def receive_from(provider, secret:)
|
|
17
|
+
self._webhook_provider = provider.to_sym
|
|
18
|
+
self._webhook_secret_resolver = secret
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Run the full receive pipeline:
|
|
23
|
+
# 1. Verify provider signature (401 on failure)
|
|
24
|
+
# 2. Store event in DB (200 silent on duplicate)
|
|
25
|
+
# 3. Enqueue ProcessJob
|
|
26
|
+
# 4. Respond 200 OK
|
|
27
|
+
def receive_webhook!
|
|
28
|
+
provider_name = self.class._webhook_provider
|
|
29
|
+
raise "No provider declared. Call receive_from :stripe, secret: -> { ... }" unless provider_name
|
|
30
|
+
|
|
31
|
+
adapter = WebhookInbox.provider_for(provider_name)
|
|
32
|
+
secret = self.class._webhook_secret_resolver.call
|
|
33
|
+
raw_body = read_request_body
|
|
34
|
+
|
|
35
|
+
verify_signature!(adapter, raw_body, secret) || return
|
|
36
|
+
store_and_process(adapter, raw_body, provider_name) || return
|
|
37
|
+
|
|
38
|
+
head :ok
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def read_request_body
|
|
44
|
+
request.body.rewind # Rewind first — middleware may have consumed the body
|
|
45
|
+
request.body.read
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def verify_signature!(adapter, raw_body, secret)
|
|
49
|
+
adapter.verify!(raw_body, request, secret: secret)
|
|
50
|
+
true
|
|
51
|
+
rescue WebhookInbox::SignatureError
|
|
52
|
+
head :unauthorized
|
|
53
|
+
false
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def store_and_process(adapter, raw_body, provider_name)
|
|
57
|
+
event = WebhookInbox::Event.create!(
|
|
58
|
+
provider: provider_name.to_s,
|
|
59
|
+
event_id: adapter.event_id(raw_body, request),
|
|
60
|
+
event_type: adapter.event_type(raw_body, request),
|
|
61
|
+
payload: raw_body,
|
|
62
|
+
status: "pending"
|
|
63
|
+
)
|
|
64
|
+
WebhookInbox::ProcessJob.set(queue: WebhookInbox.configuration.queue_name)
|
|
65
|
+
.perform_later(event.id)
|
|
66
|
+
true
|
|
67
|
+
rescue ActiveRecord::RecordNotUnique
|
|
68
|
+
head :ok # Duplicate delivery — idempotent 200
|
|
69
|
+
false
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module WebhookInbox
|
|
7
|
+
module RSpecHelpers
|
|
8
|
+
# Simulate a signed webhook delivery to the given path.
|
|
9
|
+
# Signs the payload using the provider's scheme so signature verification passes.
|
|
10
|
+
#
|
|
11
|
+
# @param provider [Symbol] e.g. :stripe
|
|
12
|
+
# @param event_type [String] e.g. "customer.subscription.created"
|
|
13
|
+
# @param payload [Hash] the event payload (will be JSON-encoded)
|
|
14
|
+
# @param path [String] the route to POST to (default: "/webhooks/#{provider}")
|
|
15
|
+
# @param event_id [String] override the generated event ID
|
|
16
|
+
# @param secret [String] the webhook secret (default: "test_secret")
|
|
17
|
+
def deliver_webhook(provider, event_type, payload: {}, path: nil, event_id: nil, secret: "test_secret")
|
|
18
|
+
path ||= "/webhooks/#{provider}"
|
|
19
|
+
headers = build_webhook_headers(provider, event_type, payload, event_id: event_id, secret: secret)
|
|
20
|
+
post path, params: headers[:body], headers: headers[:headers]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def build_webhook_headers(provider, event_type, payload, event_id:, secret:)
|
|
26
|
+
case provider.to_sym
|
|
27
|
+
when :stripe
|
|
28
|
+
build_stripe_headers(event_type, payload, event_id: event_id, secret: secret)
|
|
29
|
+
else
|
|
30
|
+
raise ArgumentError, "No RSpec helper for provider: #{provider}"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def build_stripe_headers(event_type, payload, event_id:, secret:)
|
|
35
|
+
id = event_id || "evt_test_#{SecureRandom.hex(8)}"
|
|
36
|
+
body = JSON.generate({ id: id, type: event_type, data: payload })
|
|
37
|
+
ts = Time.now.to_i.to_s
|
|
38
|
+
sig = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{ts}.#{body}")
|
|
39
|
+
|
|
40
|
+
{
|
|
41
|
+
body: body,
|
|
42
|
+
headers: {
|
|
43
|
+
"CONTENT_TYPE" => "application/json",
|
|
44
|
+
"HTTP_STRIPE_SIGNATURE" => "t=#{ts},v1=#{sig}"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support"
|
|
4
|
+
require "active_support/core_ext/module/attribute_accessors"
|
|
5
|
+
|
|
6
|
+
require_relative "webhook_inbox/version"
|
|
7
|
+
require_relative "webhook_inbox/configuration"
|
|
8
|
+
require_relative "webhook_inbox/providers/base"
|
|
9
|
+
require_relative "webhook_inbox/providers/stripe"
|
|
10
|
+
|
|
11
|
+
module WebhookInbox
|
|
12
|
+
mattr_accessor :configuration
|
|
13
|
+
|
|
14
|
+
class SignatureError < StandardError; end
|
|
15
|
+
class UnknownProviderError < StandardError; end
|
|
16
|
+
|
|
17
|
+
PROVIDERS = {
|
|
18
|
+
stripe: WebhookInbox::Providers::Stripe
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
def configure
|
|
23
|
+
self.configuration ||= Configuration.new
|
|
24
|
+
yield configuration
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def provider_for(name)
|
|
28
|
+
klass = PROVIDERS[name.to_sym]
|
|
29
|
+
raise UnknownProviderError, "Unknown provider: #{name}. Available: #{PROVIDERS.keys.join(', ')}" unless klass
|
|
30
|
+
|
|
31
|
+
klass.new
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
require_relative "webhook_inbox/engine" if defined?(Rails::Engine)
|