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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/.standard.yml +2 -0
  3. data/.yard-lint.yml +296 -0
  4. data/AGENTS.md +56 -0
  5. data/Appraisals +6 -2
  6. data/CHANGELOG.md +20 -0
  7. data/README.md +101 -11
  8. data/Rakefile +7 -0
  9. data/config/mutant.rb +37 -0
  10. data/config/mutant.yml +25 -0
  11. data/example/Gemfile +5 -6
  12. data/example/Gemfile.lock +260 -141
  13. data/example/app/webhooks/acme_handler.rb +16 -0
  14. data/example/app/webhooks/flaky_handler.rb +8 -0
  15. data/example/app/webhooks/signed_handler.rb +10 -0
  16. data/example/config/initializers/webhukhs.rb +4 -3
  17. data/example/config/routes.rb +3 -0
  18. data/example/db/migrate/20240601000000_add_headers_to_webhukhs_webhooks.rb +7 -0
  19. data/example/db/schema.rb +2 -1
  20. data/example/db/seeds.rb +71 -7
  21. data/gemfiles/{rails_7.1.gemfile → rails_7.2.gemfile} +1 -4
  22. data/gemfiles/rails_7.2.gemfile.lock +477 -0
  23. data/gemfiles/rails_8.0.gemfile +0 -3
  24. data/gemfiles/rails_8.0.gemfile.lock +74 -4
  25. data/gemfiles/rails_8.1.gemfile +0 -3
  26. data/gemfiles/rails_8.1.gemfile.lock +74 -4
  27. data/gemfiles/rails_master.gemfile +7 -0
  28. data/lib/webhukhs/admin/application_controller.rb +32 -0
  29. data/lib/webhukhs/admin/assets/admin.css +476 -0
  30. data/lib/webhukhs/admin/assets/admin.js +111 -0
  31. data/lib/webhukhs/admin/config/routes.rb +6 -0
  32. data/lib/webhukhs/admin/engine.rb +20 -0
  33. data/lib/webhukhs/admin/received_webhook_presenter.rb +63 -0
  34. data/lib/webhukhs/admin/received_webhooks_controller.rb +60 -0
  35. data/lib/webhukhs/admin/view_helper.rb +50 -0
  36. data/lib/webhukhs/admin/views/layouts/webhukhs/admin/application.html.erb +22 -0
  37. data/lib/webhukhs/admin/views/webhukhs/admin/received_webhooks/index.html.erb +50 -0
  38. data/lib/webhukhs/admin/views/webhukhs/admin/received_webhooks/show.html.erb +55 -0
  39. data/lib/webhukhs/admin.rb +17 -0
  40. data/lib/webhukhs/base_handler.rb +9 -6
  41. data/lib/webhukhs/controllers/receive_webhooks_controller.rb +26 -7
  42. data/lib/webhukhs/engine.rb +1 -4
  43. data/lib/webhukhs/install_generator.rb +9 -2
  44. data/lib/webhukhs/jobs/processing_job.rb +13 -12
  45. data/lib/webhukhs/models/received_webhook.rb +21 -9
  46. data/lib/webhukhs/version.rb +1 -1
  47. data/lib/webhukhs.rb +9 -0
  48. data/test/admin_test.rb +334 -0
  49. data/test/base_handler_test.rb +125 -0
  50. data/test/browser/admin_click_through.rb +208 -0
  51. data/test/processing_job_test.rb +152 -12
  52. data/test/received_webhook_test.rb +260 -0
  53. data/test/test_app.rb +39 -15
  54. data/test/test_helper.rb +69 -0
  55. data/test/webhukhs_test.rb +111 -49
  56. metadata +173 -11
  57. data/example/.ruby-version +0 -1
  58. 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] the request from the controller
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 = is_a?(Webhukhs::BaseHandler) ? self.class.name : to_s
15
+ handler_module_name = self.class.name
14
16
  handler_event_id = extract_event_id_from_request(action_dispatch_request)
15
17
 
16
- webhook = Webhukhs::ReceivedWebhook.new(request: action_dispatch_request, handler_event_id: handler_event_id, handler_module_name: handler_module_name)
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
- # @see Webhukhs::ReceivedWebhook#request
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, handled: true, severity: :error)
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, handled: true, severity: :error)
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 e unless handler
25
- raise e if handler.expose_errors_to_sender?
26
- Rails.error.report(e, handled: true, severity: :error)
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
- def render_error_with_status(message_str, status: :ok)
35
- json = {ok: false, error: message_str}.to_json
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:
@@ -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.is_a?(Webhukhs::ReceivedWebhook)
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 => e
48
- webhook.error!
49
- raise e
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
- # @param [ActionDispatch::Request]
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 |(header_name, header_value)|
33
- if header_name.is_a?(String) && header_name.upcase == header_name && header_value.is_a?(String)
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 #{limit} bytes"
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
- write_attribute("body", request_body_io.read.force_encoding(Encoding::BINARY))
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!("rack.input" => StringIO.new(body.to_s.b)))
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["id"].type == :integer
118
+ return if self.class.columns_hash.fetch("id").type == :integer
107
119
 
108
120
  self.id = if SecureRandom.respond_to?(:uuid_v7)
109
- # Requires Ruby 3.3+. This is preferred approach for time-sortable id's.
121
+ # Ruby 3.3+ UUIDv7 keeps inserts more index-friendly than random UUIDv4.
110
122
  SecureRandom.uuid_v7
111
123
  else
112
- # Ruby < 3.2 fallback - uuid v4
124
+ # Fallback for Rubies without UUIDv7 support.
113
125
  SecureRandom.uuid
114
126
  end
115
127
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Webhukhs
4
- VERSION = "0.6.0"
4
+ VERSION = "0.8.0"
5
5
  end
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: {}