webhukhs 0.6.0 → 0.8.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 +4 -4
- data/.standard.yml +2 -0
- data/.yard-lint.yml +296 -0
- data/AGENTS.md +56 -0
- data/Appraisals +6 -2
- data/CHANGELOG.md +20 -0
- data/README.md +101 -11
- data/Rakefile +7 -0
- data/config/mutant.rb +37 -0
- data/config/mutant.yml +25 -0
- data/example/Gemfile +5 -6
- data/example/Gemfile.lock +260 -141
- data/example/app/webhooks/acme_handler.rb +16 -0
- data/example/app/webhooks/flaky_handler.rb +8 -0
- data/example/app/webhooks/signed_handler.rb +10 -0
- data/example/config/initializers/webhukhs.rb +4 -3
- data/example/config/routes.rb +3 -0
- data/example/db/migrate/20240601000000_add_headers_to_webhukhs_webhooks.rb +7 -0
- data/example/db/schema.rb +2 -1
- data/example/db/seeds.rb +71 -7
- data/gemfiles/{rails_7.1.gemfile → rails_7.2.gemfile} +1 -4
- data/gemfiles/rails_7.2.gemfile.lock +477 -0
- data/gemfiles/rails_8.0.gemfile +0 -3
- data/gemfiles/rails_8.0.gemfile.lock +74 -4
- data/gemfiles/rails_8.1.gemfile +0 -3
- data/gemfiles/rails_8.1.gemfile.lock +74 -4
- data/gemfiles/rails_master.gemfile +7 -0
- data/lib/webhukhs/admin/application_controller.rb +32 -0
- data/lib/webhukhs/admin/assets/admin.css +476 -0
- data/lib/webhukhs/admin/assets/admin.js +111 -0
- data/lib/webhukhs/admin/config/routes.rb +6 -0
- data/lib/webhukhs/admin/engine.rb +20 -0
- data/lib/webhukhs/admin/received_webhook_presenter.rb +63 -0
- data/lib/webhukhs/admin/received_webhooks_controller.rb +60 -0
- data/lib/webhukhs/admin/view_helper.rb +50 -0
- data/lib/webhukhs/admin/views/layouts/webhukhs/admin/application.html.erb +22 -0
- data/lib/webhukhs/admin/views/webhukhs/admin/received_webhooks/index.html.erb +50 -0
- data/lib/webhukhs/admin/views/webhukhs/admin/received_webhooks/show.html.erb +55 -0
- data/lib/webhukhs/admin.rb +17 -0
- data/lib/webhukhs/base_handler.rb +9 -6
- data/lib/webhukhs/controllers/receive_webhooks_controller.rb +26 -7
- data/lib/webhukhs/engine.rb +1 -4
- data/lib/webhukhs/install_generator.rb +9 -2
- data/lib/webhukhs/jobs/processing_job.rb +13 -12
- data/lib/webhukhs/models/received_webhook.rb +21 -9
- data/lib/webhukhs/version.rb +1 -1
- data/lib/webhukhs.rb +9 -0
- data/test/admin_test.rb +334 -0
- data/test/base_handler_test.rb +125 -0
- data/test/browser/admin_click_through.rb +208 -0
- data/test/processing_job_test.rb +152 -12
- data/test/received_webhook_test.rb +260 -0
- data/test/test_app.rb +39 -15
- data/test/test_helper.rb +69 -0
- data/test/webhukhs_test.rb +111 -49
- metadata +173 -11
- data/example/.ruby-version +0 -1
- data/gemfiles/rails_7.1.gemfile.lock +0 -412
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Webhukhs
|
|
4
|
+
module Admin
|
|
5
|
+
# Lists and shows stored webhooks. Read-only: the lifecycle belongs to Webhukhs::ProcessingJob.
|
|
6
|
+
class ReceivedWebhooksController < ApplicationController
|
|
7
|
+
# Webhooks per index page
|
|
8
|
+
PAGE_SIZE = 50
|
|
9
|
+
# Columns the index can be sorted by
|
|
10
|
+
SORTABLE_COLUMNS = %w[created_at updated_at handler_module_name handler_event_id status].freeze
|
|
11
|
+
|
|
12
|
+
helper_method :handler_options, :status_options
|
|
13
|
+
|
|
14
|
+
# Renders a filtered, sorted page of webhooks.
|
|
15
|
+
#
|
|
16
|
+
# @return [void]
|
|
17
|
+
def index
|
|
18
|
+
@sort = params[:sort].presence_in(SORTABLE_COLUMNS) || "created_at"
|
|
19
|
+
@direction = (params[:direction] == "asc") ? "asc" : "desc"
|
|
20
|
+
@page = [params[:page].to_i, 1].max
|
|
21
|
+
|
|
22
|
+
filters = {handler_module_name: params[:handler], status: params[:status]}.compact_blank
|
|
23
|
+
|
|
24
|
+
# Most columns have plenty of ties (status especially), and without a tiebreaker the DB is free
|
|
25
|
+
# to return them in a different order for every OFFSET - rows then repeat or vanish between pages
|
|
26
|
+
rows = ReceivedWebhook
|
|
27
|
+
.where(filters)
|
|
28
|
+
.order(@sort => @direction, :id => @direction)
|
|
29
|
+
.offset((@page - 1) * PAGE_SIZE)
|
|
30
|
+
.limit(PAGE_SIZE + 1)
|
|
31
|
+
@next_page = @page + 1 if rows.length > PAGE_SIZE
|
|
32
|
+
@webhooks = rows.first(PAGE_SIZE).map { |webhook| ReceivedWebhookPresenter.new(webhook) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Renders one webhook with its body and headers.
|
|
36
|
+
#
|
|
37
|
+
# @return [void]
|
|
38
|
+
def show
|
|
39
|
+
@webhook = ReceivedWebhookPresenter.new(ReceivedWebhook.find(params.fetch(:id)))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# The configured handlers rather than a DISTINCT over the table, so that a handler which has
|
|
45
|
+
# not received anything yet can still be picked - "nothing arrived" is an answer too.
|
|
46
|
+
#
|
|
47
|
+
# @return [Array<String>] handler class names
|
|
48
|
+
def handler_options
|
|
49
|
+
Webhukhs.configuration.active_handlers.values.map(&:to_s).uniq.sort
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Statuses a webhook can be in, to filter by.
|
|
53
|
+
#
|
|
54
|
+
# @return [Hash{String => String}]
|
|
55
|
+
def status_options
|
|
56
|
+
ReceivedWebhook.statuses
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Webhukhs
|
|
4
|
+
module Admin
|
|
5
|
+
# Table-building helpers for the admin views.
|
|
6
|
+
module ViewHelper
|
|
7
|
+
# Renders a table inside a scroll container. Cells are `white-space: nowrap`, so one long
|
|
8
|
+
# column would otherwise take the whole page sideways with it.
|
|
9
|
+
#
|
|
10
|
+
# @param columns [Array<String>] header cell contents
|
|
11
|
+
# @param records [Array] rows to render
|
|
12
|
+
# @yieldparam record [Object] one of the records
|
|
13
|
+
# @yieldreturn [Array] cell contents for that record
|
|
14
|
+
# @return [ActiveSupport::SafeBuffer]
|
|
15
|
+
def admin_table(columns, records)
|
|
16
|
+
rows = records.map do |record|
|
|
17
|
+
tag.tr { safe_join(yield(record).map { |cell| tag.td(cell) }) }
|
|
18
|
+
end
|
|
19
|
+
rows = [tag.tr(tag.td("No records.", colspan: columns.length, class: "empty"))] if rows.empty?
|
|
20
|
+
|
|
21
|
+
thead = tag.thead(tag.tr(safe_join(columns.map { |column| tag.th(column) })))
|
|
22
|
+
tag.div(tag.table(safe_join([thead, tag.tbody(safe_join(rows))]), class: "admin-table"), class: "table-scroll")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Column header linking to the index sorted by that column. Clicking the active column
|
|
26
|
+
# flips the direction, any other column starts out descending.
|
|
27
|
+
#
|
|
28
|
+
# @param label [String] header text
|
|
29
|
+
# @param column [String] column to sort by
|
|
30
|
+
# @param sort [String] currently sorted column
|
|
31
|
+
# @param direction [String] current direction, "asc" or "desc"
|
|
32
|
+
# @return [ActiveSupport::SafeBuffer]
|
|
33
|
+
def sort_link(label, column, sort:, direction:)
|
|
34
|
+
active = column == sort
|
|
35
|
+
next_direction = (active && direction == "desc") ? "asc" : "desc"
|
|
36
|
+
query = request.query_parameters.except("page").merge("sort" => column, "direction" => next_direction)
|
|
37
|
+
label = safe_join([label, tag.span((direction == "asc") ? "▲" : "▼", class: "sort-arrow")], " ") if active
|
|
38
|
+
link_to(label, query, class: "sort-link")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Button copying a value to the clipboard (see admin.js).
|
|
42
|
+
#
|
|
43
|
+
# @param value [String] text to copy
|
|
44
|
+
# @return [ActiveSupport::SafeBuffer]
|
|
45
|
+
def copy_button(value)
|
|
46
|
+
tag.button(class: "btn-copy", type: "button", title: "Copy to clipboard", data: {copy: value})
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title><%= content_for?(:title) ? "#{yield(:title)} · Webhukhs" : "Webhukhs" %></title>
|
|
7
|
+
<link rel="icon" href="data:,">
|
|
8
|
+
<%= assiette_stylesheet_tag "/admin.css" %>
|
|
9
|
+
<script type="module" src="<%= assiette_asset_path "/admin.js" %>" integrity="<%= assiette_asset_integrity "/admin.js" %>" crossorigin="anonymous"></script>
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<header class="admin-header">
|
|
13
|
+
<strong><%= link_to "Webhukhs", root_path %></strong>
|
|
14
|
+
<nav>
|
|
15
|
+
<%= link_to "Received webhooks", received_webhooks_path %>
|
|
16
|
+
</nav>
|
|
17
|
+
</header>
|
|
18
|
+
<main>
|
|
19
|
+
<%= yield %>
|
|
20
|
+
</main>
|
|
21
|
+
</body>
|
|
22
|
+
</html>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<% content_for :title, "Received webhooks" %>
|
|
2
|
+
<div class="page-heading">
|
|
3
|
+
<h1>Received webhooks</h1>
|
|
4
|
+
<%# Off by default, admin.js remembers the choice and refetches the listing while it is on %>
|
|
5
|
+
<label class="switch">
|
|
6
|
+
<input type="checkbox" id="auto-refresh" role="switch">
|
|
7
|
+
Auto-refresh
|
|
8
|
+
</label>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<%= form_tag received_webhooks_path, method: :get, class: "filters" do %>
|
|
12
|
+
<%= hidden_field_tag :sort, @sort, id: nil %>
|
|
13
|
+
<%= hidden_field_tag :direction, @direction, id: nil %>
|
|
14
|
+
<%= select_tag :handler, options_for_select([["Any handler", ""]] + handler_options, params[:handler]), "aria-label": "Handler" %>
|
|
15
|
+
<%= select_tag :status, options_for_select([["Any status", ""]] + status_options.map { |status, _| [status.humanize, status] }, params[:status]), "aria-label": "Status" %>
|
|
16
|
+
<%= submit_tag "Filter", name: nil %>
|
|
17
|
+
<%= link_to "Clear", received_webhooks_path(sort: @sort, direction: @direction) %>
|
|
18
|
+
<% end %>
|
|
19
|
+
|
|
20
|
+
<%
|
|
21
|
+
columns = [
|
|
22
|
+
["Received", "created_at"],
|
|
23
|
+
["Handler", "handler_module_name"],
|
|
24
|
+
["Status", "status"],
|
|
25
|
+
["Event id", "handler_event_id"],
|
|
26
|
+
["Updated", "updated_at"]
|
|
27
|
+
].map { |label, column| sort_link(label, column, sort: @sort, direction: @direction) }
|
|
28
|
+
%>
|
|
29
|
+
<div id="received-webhooks">
|
|
30
|
+
<%# The id cell carries the link, and the CSS stretches it over the whole row %>
|
|
31
|
+
<%= admin_table(["Id"] + columns, @webhooks) do |webhook|
|
|
32
|
+
[
|
|
33
|
+
link_to(tag.code(webhook.id, class: "cell-ellipsis cell-id", title: webhook.id), received_webhook_path(webhook), class: "row-link"),
|
|
34
|
+
time_tag(webhook.created_at, webhook.created_at.iso8601),
|
|
35
|
+
webhook.handler_module_name,
|
|
36
|
+
tag.span(webhook.status, class: ["badge", webhook.status_class]),
|
|
37
|
+
tag.code(webhook.handler_event_id, class: "cell-ellipsis", title: webhook.handler_event_id),
|
|
38
|
+
time_tag(webhook.updated_at, webhook.updated_at.iso8601)
|
|
39
|
+
]
|
|
40
|
+
end %>
|
|
41
|
+
|
|
42
|
+
<nav class="pagination">
|
|
43
|
+
<% if @page > 1 %>
|
|
44
|
+
<%= link_to "← Previous", request.query_parameters.merge("page" => @page - 1), rel: "prev" %>
|
|
45
|
+
<% end %>
|
|
46
|
+
<% if @next_page %>
|
|
47
|
+
<%= link_to "Next →", request.query_parameters.merge("page" => @next_page), rel: "next" %>
|
|
48
|
+
<% end %>
|
|
49
|
+
</nav>
|
|
50
|
+
</div>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<% content_for :title, "Webhook #{@webhook.id}" %>
|
|
2
|
+
<nav class="breadcrumb">
|
|
3
|
+
<%= link_to "Received webhooks", received_webhooks_path %> / <code><%= @webhook.id %></code>
|
|
4
|
+
</nav>
|
|
5
|
+
|
|
6
|
+
<h1><%= @webhook.handler_module_name %> <span class="badge <%= @webhook.status_class %>"><%= @webhook.status %></span></h1>
|
|
7
|
+
|
|
8
|
+
<h2>Attributes</h2>
|
|
9
|
+
<div class="table-scroll attr-scroll">
|
|
10
|
+
<table class="admin-table attr-table">
|
|
11
|
+
<tbody>
|
|
12
|
+
<tr><th>Id</th><td><code><%= @webhook.id %></code> <%= copy_button(@webhook.id.to_s) %></td></tr>
|
|
13
|
+
<tr><th>Handler</th><td><code><%= @webhook.handler_module_name %></code></td></tr>
|
|
14
|
+
<tr><th>Event id</th><td><code><%= @webhook.handler_event_id %></code> <%= copy_button(@webhook.handler_event_id) %></td></tr>
|
|
15
|
+
<tr><th>Status</th><td><span class="badge <%= @webhook.status_class %>"><%= @webhook.status %></span></td></tr>
|
|
16
|
+
<tr><th>Received</th><td><%= time_tag(@webhook.created_at, @webhook.created_at.iso8601) %></td></tr>
|
|
17
|
+
<tr><th>Updated</th><td><%= time_tag(@webhook.updated_at, @webhook.updated_at.iso8601) %></td></tr>
|
|
18
|
+
</tbody>
|
|
19
|
+
</table>
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
<h2>Body <span class="muted">(<%= number_to_human_size(@webhook.body.bytesize) %>)</span></h2>
|
|
23
|
+
<% if @webhook.truncated? %>
|
|
24
|
+
<p class="flash alert">The body was <%= number_to_human_size(@webhook.body.bytesize) %>, display is truncated</p>
|
|
25
|
+
<pre class="body-detail body-raw"><code><%= @webhook.body_text %></code></pre>
|
|
26
|
+
<% elsif (pretty_json = @webhook.pretty_json) %>
|
|
27
|
+
<div class="tabs">
|
|
28
|
+
<div class="tab-bar">
|
|
29
|
+
<div role="tablist" aria-label="Body">
|
|
30
|
+
<button type="button" role="tab" id="body-tab-pretty" aria-controls="body-pretty" aria-selected="true">Pretty</button>
|
|
31
|
+
<button type="button" role="tab" id="body-tab-raw" aria-controls="body-raw" aria-selected="false" tabindex="-1">As sent</button>
|
|
32
|
+
</div>
|
|
33
|
+
<button type="button" class="btn-copy-panel" title="Copy the visible body to the clipboard">Copy</button>
|
|
34
|
+
</div>
|
|
35
|
+
<div role="tabpanel" id="body-pretty" aria-labelledby="body-tab-pretty">
|
|
36
|
+
<pre class="body-detail"><code><%= pretty_json %></code></pre>
|
|
37
|
+
</div>
|
|
38
|
+
<div role="tabpanel" id="body-raw" aria-labelledby="body-tab-raw" hidden>
|
|
39
|
+
<pre class="body-detail body-raw"><code><%= @webhook.body_text %></code></pre>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
<% else %>
|
|
43
|
+
<p class="flash alert">This body is not valid JSON, so it is shown as it was sent</p>
|
|
44
|
+
<pre class="body-detail body-raw"><code><%= @webhook.body_text %></code></pre>
|
|
45
|
+
<% end %>
|
|
46
|
+
|
|
47
|
+
<h2>Request headers</h2>
|
|
48
|
+
<p class="muted">
|
|
49
|
+
As presented by the web server and any proxies in front of it.
|
|
50
|
+
<code>action_dispatch.request.path_parameters</code> is not a header, but the parameters
|
|
51
|
+
the router matched for the request.
|
|
52
|
+
</p>
|
|
53
|
+
<%= admin_table(["Header", "Value"], @webhook.header_rows) do |name, value|
|
|
54
|
+
[tag.code(name), tag.code(value, class: "header-value")]
|
|
55
|
+
end %>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "assiette"
|
|
4
|
+
require_relative "../webhukhs"
|
|
5
|
+
require_relative "admin/engine"
|
|
6
|
+
require_relative "admin/view_helper"
|
|
7
|
+
require_relative "admin/received_webhook_presenter"
|
|
8
|
+
require_relative "admin/application_controller"
|
|
9
|
+
require_relative "admin/received_webhooks_controller"
|
|
10
|
+
|
|
11
|
+
# Public namespace for Webhukhs runtime and configuration.
|
|
12
|
+
module Webhukhs
|
|
13
|
+
# Optional, read-only admin UI for received webhooks. It is not loaded by `require "webhukhs"` -
|
|
14
|
+
# require "webhukhs/admin" and mount {Webhukhs::Admin::Engine} yourself, behind your own authentication.
|
|
15
|
+
module Admin
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -2,18 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "jobs/processing_job"
|
|
4
4
|
|
|
5
|
+
# Namespace for webhook ingestion and processing classes.
|
|
5
6
|
module Webhukhs
|
|
7
|
+
# Base class for webhook handlers.
|
|
6
8
|
class BaseHandler
|
|
7
9
|
# `handle` accepts the ActionDispatch HTTP request and saves the webhook for later processing. It then
|
|
8
10
|
# enqueues an ActiveJob which will perform the processing using `process`.
|
|
9
11
|
#
|
|
10
|
-
# @param action_dispatch_request[ActionDispatch::Request]
|
|
12
|
+
# @param action_dispatch_request [ActionDispatch::Request] request from the controller
|
|
11
13
|
# @return [void]
|
|
12
14
|
def handle(action_dispatch_request)
|
|
13
|
-
handler_module_name =
|
|
15
|
+
handler_module_name = self.class.name
|
|
14
16
|
handler_event_id = extract_event_id_from_request(action_dispatch_request)
|
|
15
17
|
|
|
16
|
-
webhook =
|
|
18
|
+
webhook = ReceivedWebhook.new(request: action_dispatch_request, handler_event_id: handler_event_id, handler_module_name: handler_module_name)
|
|
17
19
|
webhook.save!
|
|
18
20
|
|
|
19
21
|
enqueue(webhook)
|
|
@@ -42,7 +44,7 @@ module Webhukhs
|
|
|
42
44
|
# body of the webhook request, but also the full (as-full-as-possible) clone of the original ActionDispatch::Request
|
|
43
45
|
# that you can use.
|
|
44
46
|
#
|
|
45
|
-
# @param received_webhook[Webhukhs::ReceivedWebhook]
|
|
47
|
+
# @param received_webhook [Webhukhs::ReceivedWebhook] persisted webhook to process
|
|
46
48
|
# @return [void]
|
|
47
49
|
def process(received_webhook)
|
|
48
50
|
end
|
|
@@ -60,9 +62,9 @@ module Webhukhs
|
|
|
60
62
|
# If this method returns `false`, the webhook will be marked as `failed_validation` in the database. If this
|
|
61
63
|
# method returns `true`, the `process` method of the handler is going to be called.
|
|
62
64
|
#
|
|
63
|
-
# @
|
|
64
|
-
# @param action_dispatch_request[ActionDispatch::Request] the reconstructed request from the controller
|
|
65
|
+
# @param action_dispatch_request [ActionDispatch::Request] reconstructed request from the stored webhook
|
|
65
66
|
# @return [Boolean]
|
|
67
|
+
# @see Webhukhs::ReceivedWebhook#request
|
|
66
68
|
def valid?(action_dispatch_request)
|
|
67
69
|
true
|
|
68
70
|
end
|
|
@@ -71,6 +73,7 @@ module Webhukhs
|
|
|
71
73
|
# an event ID we use it for deduplication. A duplicate webhook is not going to be
|
|
72
74
|
# stored in the database if it is already present there.
|
|
73
75
|
#
|
|
76
|
+
# @param action_dispatch_request [ActionDispatch::Request] request used to derive a stable event identifier
|
|
74
77
|
# @return [String]
|
|
75
78
|
def extract_event_id_from_request(action_dispatch_request)
|
|
76
79
|
SecureRandom.uuid
|
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Webhukhs
|
|
4
|
+
# Receives incoming webhook HTTP requests and dispatches handlers.
|
|
4
5
|
class ReceiveWebhooksController < ActionController::API
|
|
6
|
+
# Raised when a configured handler exists but is currently inactive.
|
|
5
7
|
class HandlerInactive < StandardError
|
|
6
8
|
end
|
|
7
9
|
|
|
10
|
+
# Raised when no handler is configured for the service id.
|
|
8
11
|
class UnknownHandler < StandardError
|
|
9
12
|
end
|
|
10
13
|
|
|
14
|
+
# Persists and schedules processing for an incoming webhook request.
|
|
15
|
+
#
|
|
16
|
+
# @return [void]
|
|
11
17
|
def create
|
|
12
18
|
Rails.error.set_context(**Webhukhs.configuration.error_context)
|
|
13
19
|
handler = lookup_handler(service_id)
|
|
@@ -15,27 +21,40 @@ module Webhukhs
|
|
|
15
21
|
handler.handle(request)
|
|
16
22
|
render(json: {ok: true, error: nil})
|
|
17
23
|
rescue UnknownHandler => e
|
|
18
|
-
Rails.error.report(e,
|
|
24
|
+
Rails.error.report(e, severity: :error)
|
|
19
25
|
render_error_with_status("No handler found for #{service_id.inspect}", status: :not_found)
|
|
20
26
|
rescue HandlerInactive => e
|
|
21
|
-
Rails.error.report(e,
|
|
27
|
+
Rails.error.report(e, severity: :error)
|
|
22
28
|
render_error_with_status("Webhook handler #{service_id.inspect} is inactive", status: :service_unavailable)
|
|
23
29
|
rescue => e
|
|
24
|
-
raise
|
|
25
|
-
raise
|
|
26
|
-
Rails.error.report(e,
|
|
30
|
+
raise unless handler
|
|
31
|
+
raise if handler.expose_errors_to_sender?
|
|
32
|
+
Rails.error.report(e, severity: :error)
|
|
27
33
|
render_error_with_status("Internal error (#{e})")
|
|
28
34
|
end
|
|
29
35
|
|
|
36
|
+
# Extracts webhook service identifier from route parameters.
|
|
37
|
+
#
|
|
38
|
+
# @return [String]
|
|
30
39
|
def service_id
|
|
31
40
|
params.require(:service_id)
|
|
32
41
|
end
|
|
33
42
|
|
|
34
|
-
|
|
35
|
-
|
|
43
|
+
# Renders standardized error response payload.
|
|
44
|
+
#
|
|
45
|
+
# @param message_str [String] human-readable error message
|
|
46
|
+
# @param status [Symbol] HTTP response status
|
|
47
|
+
# @return [void]
|
|
48
|
+
def render_error_with_status(message_str, status: nil)
|
|
49
|
+
json = {ok: false, error: message_str}
|
|
36
50
|
render(json: json, status: status)
|
|
37
51
|
end
|
|
38
52
|
|
|
53
|
+
# Resolves a handler class from configuration for a service id.
|
|
54
|
+
#
|
|
55
|
+
# @param service_id_str [String] service identifier from request path
|
|
56
|
+
# @return [Webhukhs::BaseHandler]
|
|
57
|
+
# @raise [UnknownHandler] when no active handler is configured for service id
|
|
39
58
|
def lookup_handler(service_id_str)
|
|
40
59
|
active_handlers = Webhukhs.configuration.active_handlers.with_indifferent_access
|
|
41
60
|
# The config can specify a mapping of:
|
data/lib/webhukhs/engine.rb
CHANGED
|
@@ -6,6 +6,7 @@ require_relative "models/received_webhook"
|
|
|
6
6
|
require_relative "base_handler"
|
|
7
7
|
|
|
8
8
|
module Webhukhs
|
|
9
|
+
# Rails engine wiring routes, autoloading and generators for Webhukhs.
|
|
9
10
|
class Engine < ::Rails::Engine
|
|
10
11
|
isolate_namespace Webhukhs
|
|
11
12
|
|
|
@@ -16,9 +17,5 @@ module Webhukhs
|
|
|
16
17
|
generators do
|
|
17
18
|
require_relative "install_generator"
|
|
18
19
|
end
|
|
19
|
-
|
|
20
|
-
routes do
|
|
21
|
-
post "/:service_id" => "received_webhooks#create"
|
|
22
|
-
end
|
|
23
20
|
end
|
|
24
21
|
end
|
|
@@ -4,26 +4,33 @@ require "rails/generators"
|
|
|
4
4
|
require "rails/generators/active_record"
|
|
5
5
|
|
|
6
6
|
module Webhukhs
|
|
7
|
-
#
|
|
8
7
|
# Rails generator used for setting up Webhukhs in a Rails application.
|
|
9
8
|
# Run it with +bin/rails g webhukhs:install+ in your console.
|
|
10
|
-
#
|
|
11
9
|
class InstallGenerator < Rails::Generators::Base
|
|
12
10
|
include ActiveRecord::Generators::Migration
|
|
13
11
|
|
|
14
12
|
source_root File.expand_path("../templates", __FILE__)
|
|
15
13
|
|
|
14
|
+
# Creates migration files required by Webhukhs tables.
|
|
15
|
+
#
|
|
16
|
+
# @return [void]
|
|
16
17
|
def create_migration_file
|
|
17
18
|
migration_template "create_webhukhs_tables.rb.erb", File.join(db_migrate_path, "create_webhukhs_tables.rb")
|
|
18
19
|
migration_template "add_headers_to_webhukhs_webhooks.rb.erb", File.join(db_migrate_path, "add_headers_to_webhukhs_webhooks.rb")
|
|
19
20
|
end
|
|
20
21
|
|
|
22
|
+
# Copies initializer file into host application.
|
|
23
|
+
#
|
|
24
|
+
# @return [void]
|
|
21
25
|
def copy_files
|
|
22
26
|
template "webhukhs.rb", File.join("config", "initializers", "webhukhs.rb")
|
|
23
27
|
end
|
|
24
28
|
|
|
25
29
|
private
|
|
26
30
|
|
|
31
|
+
# Declares ActiveRecord migration version.
|
|
32
|
+
#
|
|
33
|
+
# @return [String]
|
|
27
34
|
def migration_version
|
|
28
35
|
"[#{ActiveRecord::VERSION::STRING.to_f}]"
|
|
29
36
|
end
|
|
@@ -3,35 +3,36 @@
|
|
|
3
3
|
require "active_job/railtie"
|
|
4
4
|
|
|
5
5
|
module Webhukhs
|
|
6
|
+
# Background job that validates and processes a persisted webhook.
|
|
6
7
|
class ProcessingJob < ActiveJob::Base
|
|
8
|
+
# Raised when the job receives an invalid webhook argument.
|
|
7
9
|
class InvalidWebhookArgument < StandardError; end
|
|
8
10
|
|
|
9
|
-
discard_on ActiveJob::DeserializationError do |job, error|
|
|
10
|
-
Rails.error.report(error, context: {
|
|
11
|
-
job_id: job.job_id,
|
|
12
|
-
arguments: job.arguments.map(&:inspect)
|
|
13
|
-
}, severity: :error)
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
discard_on InvalidWebhookArgument do |job, error|
|
|
11
|
+
discard_on ActiveJob::DeserializationError, InvalidWebhookArgument do |job, error|
|
|
17
12
|
Rails.error.report(error, context: {
|
|
18
13
|
job_id: job.job_id,
|
|
19
14
|
arguments: job.arguments.map(&:inspect)
|
|
20
15
|
}, severity: :error)
|
|
21
16
|
end
|
|
22
17
|
|
|
18
|
+
# Runs webhook validation and processing lifecycle.
|
|
19
|
+
#
|
|
20
|
+
# @param webhook [Webhukhs::ReceivedWebhook] webhook record to process
|
|
21
|
+
# @return [void]
|
|
23
22
|
def perform(webhook)
|
|
24
23
|
raise InvalidWebhookArgument, "ProcessingJob received nil webhook argument" if webhook.nil?
|
|
25
|
-
unless webhook.
|
|
24
|
+
unless webhook.instance_of?(ReceivedWebhook)
|
|
26
25
|
raise InvalidWebhookArgument, "ProcessingJob expected Webhukhs::ReceivedWebhook, got #{webhook.class}"
|
|
27
26
|
end
|
|
28
27
|
|
|
29
28
|
webhook_details_for_logs = "Webhukhs::ReceivedWebhook#%s (handler: %s)" % [webhook.id, webhook.handler]
|
|
29
|
+
|
|
30
30
|
webhook.with_lock do
|
|
31
31
|
unless webhook.received?
|
|
32
32
|
logger.info { "#{webhook_details_for_logs} is being processed in a different job or has been processed already, skipping." }
|
|
33
33
|
return
|
|
34
34
|
end
|
|
35
|
+
|
|
35
36
|
webhook.processing!
|
|
36
37
|
end
|
|
37
38
|
|
|
@@ -44,9 +45,9 @@ module Webhukhs
|
|
|
44
45
|
logger.info { "#{webhook_details_for_logs} did not pass validation by the handler. Marking it `failed_validation`." }
|
|
45
46
|
webhook.failed_validation!
|
|
46
47
|
end
|
|
47
|
-
rescue
|
|
48
|
-
webhook.error!
|
|
49
|
-
raise
|
|
48
|
+
rescue
|
|
49
|
+
webhook.error! if webhook.respond_to?(:error!)
|
|
50
|
+
raise
|
|
50
51
|
end
|
|
51
52
|
end
|
|
52
53
|
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "state_machine_enum"
|
|
4
4
|
|
|
5
5
|
module Webhukhs
|
|
6
|
+
# ActiveRecord model that stores received webhook requests for async processing.
|
|
6
7
|
class ReceivedWebhook < ActiveRecord::Base
|
|
7
8
|
self.implicit_order_column = "created_at"
|
|
8
9
|
self.table_name = "received_webhooks"
|
|
@@ -25,12 +26,14 @@ module Webhukhs
|
|
|
25
26
|
end
|
|
26
27
|
|
|
27
28
|
# Store the pertinent data from an ActionDispatch::Request into the webhook.
|
|
28
|
-
#
|
|
29
|
+
#
|
|
30
|
+
# @param action_dispatch_request [ActionDispatch::Request] request to persist
|
|
31
|
+
# @return [void]
|
|
29
32
|
def request=(action_dispatch_request)
|
|
30
33
|
# Filter out all Rack-specific headers such as "rack.input" and the like. We are
|
|
31
34
|
# only interested in the actual HTTP headers presented by the webserver. Mostly...
|
|
32
|
-
headers = action_dispatch_request.env.filter_map do |
|
|
33
|
-
if header_name.
|
|
35
|
+
headers = action_dispatch_request.env.filter_map do |header_name, header_value|
|
|
36
|
+
if header_name.instance_of?(String) && header_name.upcase == header_name && header_value.instance_of?(String)
|
|
34
37
|
[header_name, header_value]
|
|
35
38
|
end
|
|
36
39
|
end.to_h
|
|
@@ -47,10 +50,13 @@ module Webhukhs
|
|
|
47
50
|
# Verify the request body is not too large
|
|
48
51
|
request_body_io = action_dispatch_request.env.fetch("rack.input")
|
|
49
52
|
if request_body_io.size > Webhukhs.configuration.request_body_size_limit
|
|
50
|
-
raise "Cannot accept the webhook as the request body is larger than #{
|
|
53
|
+
raise "Cannot accept the webhook as the request body is larger than #{Webhukhs.configuration.request_body_size_limit} bytes"
|
|
51
54
|
end
|
|
52
55
|
|
|
53
|
-
|
|
56
|
+
# Rails parses a form-encoded body into params before the handler gets the request, and
|
|
57
|
+
# leaves the stream at its end - we would store an empty body without the rewind
|
|
58
|
+
request_body_io.rewind
|
|
59
|
+
write_attribute("body", request_body_io.read.b)
|
|
54
60
|
write_attribute("request_headers", headers)
|
|
55
61
|
ensure
|
|
56
62
|
request_body_io.rewind
|
|
@@ -91,25 +97,31 @@ module Webhukhs
|
|
|
91
97
|
#
|
|
92
98
|
# @return [ActionDispatch::Request]
|
|
93
99
|
def request
|
|
94
|
-
ActionDispatch::Request.new(request_headers.merge
|
|
100
|
+
ActionDispatch::Request.new(request_headers.merge("rack.input" => StringIO.new(body.b)))
|
|
95
101
|
end
|
|
96
102
|
|
|
103
|
+
# Instantiates the configured handler for this webhook.
|
|
104
|
+
#
|
|
105
|
+
# @return [Webhukhs::BaseHandler]
|
|
97
106
|
def handler
|
|
98
107
|
handler_module_name.constantize.new
|
|
99
108
|
end
|
|
100
109
|
|
|
101
110
|
private
|
|
102
111
|
|
|
112
|
+
# Assigns a generated id when database does not auto-increment ids.
|
|
113
|
+
#
|
|
114
|
+
# @return [void]
|
|
103
115
|
def ensure_id
|
|
104
116
|
return if id.present?
|
|
105
117
|
# let DB auto-increment handle it
|
|
106
|
-
return if self.class.columns_hash
|
|
118
|
+
return if self.class.columns_hash.fetch("id").type == :integer
|
|
107
119
|
|
|
108
120
|
self.id = if SecureRandom.respond_to?(:uuid_v7)
|
|
109
|
-
#
|
|
121
|
+
# Ruby 3.3+ UUIDv7 keeps inserts more index-friendly than random UUIDv4.
|
|
110
122
|
SecureRandom.uuid_v7
|
|
111
123
|
else
|
|
112
|
-
#
|
|
124
|
+
# Fallback for Rubies without UUIDv7 support.
|
|
113
125
|
SecureRandom.uuid
|
|
114
126
|
end
|
|
115
127
|
end
|
data/lib/webhukhs/version.rb
CHANGED
data/lib/webhukhs.rb
CHANGED
|
@@ -5,16 +5,25 @@ require_relative "webhukhs/engine"
|
|
|
5
5
|
require_relative "webhukhs/jobs/processing_job"
|
|
6
6
|
require "active_support/core_ext/class/attribute"
|
|
7
7
|
|
|
8
|
+
# Public namespace for Webhukhs runtime and configuration.
|
|
8
9
|
module Webhukhs
|
|
10
|
+
# Returns singleton configuration object.
|
|
11
|
+
#
|
|
12
|
+
# @return [Webhukhs::Configuration]
|
|
9
13
|
def self.configuration
|
|
10
14
|
@configuration ||= Configuration.new
|
|
11
15
|
end
|
|
12
16
|
|
|
17
|
+
# Yields global configuration object for setup.
|
|
18
|
+
#
|
|
19
|
+
# @yieldparam configuration [Webhukhs::Configuration]
|
|
20
|
+
# @return [void]
|
|
13
21
|
def self.configure
|
|
14
22
|
yield configuration
|
|
15
23
|
end
|
|
16
24
|
end
|
|
17
25
|
|
|
26
|
+
# Holds runtime configuration for Webhukhs.
|
|
18
27
|
class Webhukhs::Configuration
|
|
19
28
|
class_attribute :processing_job_class, default: Webhukhs::ProcessingJob
|
|
20
29
|
class_attribute :active_handlers, default: {}
|