fuik 0.5.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 (39) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +165 -0
  4. data/Rakefile +17 -0
  5. data/app/assets/stylesheets/fuik/application.css +100 -0
  6. data/app/controllers/concerns/fuik/event_type.rb +58 -0
  7. data/app/controllers/fuik/application_controller.rb +4 -0
  8. data/app/controllers/fuik/events_controller.rb +15 -0
  9. data/app/controllers/fuik/webhooks_controller.rb +71 -0
  10. data/app/jobs/fuik/application_job.rb +4 -0
  11. data/app/models/fuik/application_record.rb +5 -0
  12. data/app/models/fuik/event.rb +17 -0
  13. data/app/models/fuik/webhook_event.rb +26 -0
  14. data/app/views/fuik/events/index.html.erb +15 -0
  15. data/app/views/fuik/events/show.html.erb +31 -0
  16. data/app/views/layouts/fuik/application.html.erb +17 -0
  17. data/config/routes.rb +8 -0
  18. data/db/migrate/20250101000000_create_webhook_events.rb +22 -0
  19. data/lib/fuik/engine.rb +10 -0
  20. data/lib/fuik/version.rb +3 -0
  21. data/lib/fuik.rb +6 -0
  22. data/lib/generators/fuik/install/install_generator.rb +21 -0
  23. data/lib/generators/fuik/install/templates/README +24 -0
  24. data/lib/generators/fuik/provider/provider_generator.rb +50 -0
  25. data/lib/generators/fuik/provider/templates/README +23 -0
  26. data/lib/generators/fuik/provider/templates/base.rb.tt +8 -0
  27. data/lib/generators/fuik/provider/templates/event.rb.tt +10 -0
  28. data/lib/generators/fuik/provider/templates/github/base.rb.tt +11 -0
  29. data/lib/generators/fuik/provider/templates/github/installation_created.rb.tt +12 -0
  30. data/lib/generators/fuik/provider/templates/github/push.rb.tt +14 -0
  31. data/lib/generators/fuik/provider/templates/github/star_created.rb.tt +12 -0
  32. data/lib/generators/fuik/provider/templates/mailpace/base.rb.tt +19 -0
  33. data/lib/generators/fuik/provider/templates/mailpace/email_bounced.rb.tt +11 -0
  34. data/lib/generators/fuik/provider/templates/mailpace/email_spam.rb.tt +9 -0
  35. data/lib/generators/fuik/provider/templates/stripe/base.rb.tt +16 -0
  36. data/lib/generators/fuik/provider/templates/stripe/checkout_session_completed.rb.tt +12 -0
  37. data/lib/generators/fuik/provider/templates/stripe/customer_subscription_deleted.rb.tt +11 -0
  38. data/lib/generators/fuik/provider/templates/stripe/customer_subscription_updated.rb.tt +11 -0
  39. metadata +93 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1b6a7e1fbd1184f851bc0d1cd17013aaf4d84575e8e33ad6db6c16b7a428b3c2
4
+ data.tar.gz: 41ffd19e734216ca8b5503b461ffb600e9749de0610e27899c7b672922a25ae0
5
+ SHA512:
6
+ metadata.gz: 2f232b48c331815d4aa07fe0796ba7cecddeab26a835950ac1e874c5a602de8a76ad73eedd0c58dfdd0f7f7d6850205989666434ef1e76f3f709cb7297e3c710
7
+ data.tar.gz: 229f5f61ec154a18e2213f3131150c3013430f1025457db1a91d84cfe4bab680c13cdb815152544b84ae32483bf6a3293c3d00a591b5f08da95258d42e330209
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright Rails Designer
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,165 @@
1
+ # Fuik
2
+
3
+ **A fish trap for webhooks**
4
+
5
+ Fuik (Dutch for fish trap) is a Rails engine that catches and stores webhooks from any provider. View all events in the admin interface, then create event classes to add your business logic.
6
+
7
+
8
+ **Sponsored By [Rails Designer](https://railsdesigner.com/)**
9
+
10
+ <a href="https://railsdesigner.com/" target="_blank">
11
+ <picture>
12
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/Rails-Designer/fuik/HEAD/.github/logo-dark.svg">
13
+ <source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/Rails-Designer/fuik/HEAD/.github/logo-light.svg">
14
+ <img alt="Rails Designer" src="https://raw.githubusercontent.com/Rails-Designer/fuik/HEAD/.github/logo-light.svg" width="240" style="max-width: 100%;">
15
+ </picture>
16
+ </a>
17
+
18
+
19
+ ## Quick start
20
+
21
+ ```bash
22
+ # Install
23
+ bundle add fuik
24
+ bin/rails generate fuik:install
25
+ bin/rails db:migrate
26
+
27
+ # Point your webhook to
28
+ POST https://yourdomain.com/webhooks/stripe
29
+ ```
30
+
31
+ That's it. Webhooks are captured and visible at `/webhooks`.
32
+
33
+
34
+ ## Installation
35
+
36
+ Add to your Gemfile:
37
+ ```ruby
38
+ gem "fuik"
39
+ ```
40
+
41
+ Then run:
42
+ ```bash
43
+ bundle install
44
+ bin/rails generate fuik:install
45
+ bin/rails db:migrate
46
+ ```
47
+
48
+ The engine mounts at `/webhooks` automatically.
49
+
50
+
51
+ ## Usage
52
+
53
+ ### View events
54
+
55
+ Visit `/webhooks` to see all received webhooks. Click any event to see the full payload, headers and status.
56
+
57
+ <img alt="Fuik admin interface" src="https://raw.githubusercontent.com/Rails-Designer/fuik/HEAD/.github/docs/webhooks-index.jpg" style="max-width: 100%;">
58
+
59
+
60
+ ### Add business logic
61
+
62
+ Generate event handlers when you're ready to automate:
63
+ ```bash
64
+ bin/rails generate fuik:provider stripe checkout_session_completed customer_subscription_updated
65
+ ```
66
+
67
+ This creates:
68
+ - `app/webhooks/stripe/base.rb`
69
+ - `app/webhooks/stripe/checkout_session_completed.rb`
70
+ - `app/webhooks/stripe/customer_subscription_updated.rb`
71
+
72
+ Each class is a thin wrapper around your business logic:
73
+ ```ruby
74
+ module Stripe
75
+ class CheckoutSessionCompleted < Base
76
+ def process!
77
+ User.find_by(id: payload.dig("client_reference_id")).tap do |user|
78
+ user.activate_subscription!
79
+ user.send_welcome_email
80
+
81
+ # etc.
82
+ end
83
+
84
+ @webhook_event.processed!
85
+ end
86
+ end
87
+ end
88
+ ```
89
+
90
+ Implement `Base.verify!` to enable signature verification:
91
+ ```ruby
92
+ module Stripe
93
+ class Base < Fuik::Event
94
+ def self.verify!(request)
95
+ secret = Rails.application.credentials.dig(:stripe, :signing_secret)
96
+ signature = request.headers["Stripe-Signature"]
97
+
98
+ Stripe::Webhook.construct_event(
99
+ request.raw_post,
100
+ signature,
101
+ secret
102
+ )
103
+ rescue Stripe::SignatureVerificationError => error
104
+ raise Fuik::InvalidSignature, error.message
105
+ end
106
+ end
107
+ end
108
+ ```
109
+
110
+ If `Provider::Base.verify!` exists, Fuik calls it automatically. Invalid signatures return 401 without storing the webhook.
111
+
112
+
113
+ ### Pre-packaged providers
114
+
115
+ Fuik includes ready-to-use [templates for common providers](https://github.com/Rails-Desinger/fuik/tree/main/lib/generators/fuik/provider/templates).
116
+
117
+
118
+ ### Event type & ID lookup
119
+
120
+ Fuik automatically extracts event types and IDs from common locations:
121
+
122
+ **Event Type:**
123
+ 1. provider config (if exists);
124
+ 2. common headers (`X-Github-Event`, `X-Event-Type`, etc.);
125
+ 3. payload (`type`, `event`, `event_type`);
126
+ 4. falls back to `"unknown"`.
127
+
128
+ **Event ID:**
129
+ 1. provider config (if exists);
130
+ 2. common headers (`X-GitHub-Delivery`, `X-Event-Id`, etc.);
131
+ 3. payload (`id`).
132
+ 4. falls back to MD5 hash of request body.
133
+
134
+
135
+ #### Custom lookup via config
136
+
137
+ Create `app/webhooks/provider_name/config.yml`:
138
+ ```yaml
139
+ event_type:
140
+ source: header
141
+ key: X-Custom-Event
142
+
143
+ event_id:
144
+ source: payload
145
+ key: custom_id
146
+ ```
147
+
148
+
149
+ ## Add your custom provider
150
+
151
+ Have a provider template others could use? Add it to [lib/generators/fuik/provider/templates/your_provider/](https://github.com/Rails-Desinger/fuik/tree/main/lib/generators/fuik/provider/templates) and submit a PR!
152
+
153
+ Include:
154
+ - `base.rb.tt` with signature verification (if applicable);
155
+ - event class templates with helpful TODO comments.
156
+
157
+
158
+ ## Contributing
159
+
160
+ This project uses [Standard](https://github.com/testdouble/standard) for formatting Ruby code. Please make sure to run `rake` before submitting pull requests.
161
+
162
+
163
+ ## License
164
+
165
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "bundler/gem_tasks"
5
+ require "rake/testtask"
6
+
7
+ Rake::TestTask.new do |t|
8
+ t.libs << "test"
9
+
10
+ t.test_files = FileList["test/**/*_test.rb"]
11
+ t.verbose = true
12
+ t.warning = true
13
+ end
14
+
15
+ require "standard/rake"
16
+
17
+ task default: %i[test standard]
@@ -0,0 +1,100 @@
1
+ @layer base, components;
2
+
3
+ @layer base {
4
+ :root {
5
+ --color-text: oklch(30% 0.02 250);
6
+ --color-text-muted: oklch(55% 0.02 250);
7
+ --color-border: oklch(90% 0.01 250);
8
+ --color-bg-hover: oklch(97% 0.01 250);
9
+ }
10
+
11
+ * { box-sizing: border-box; }
12
+
13
+ body {
14
+ margin: 0;
15
+ padding: 2rem;
16
+ max-inline-size: 80rem;
17
+ margin-inline: auto;
18
+ font-family: system-ui, -apple-system, sans-serif;
19
+ line-height: 1.5;
20
+ color: var(--color-text);
21
+ }
22
+
23
+ h1 {
24
+ margin-block-end: 1rem;
25
+ font-size: 1.875rem;
26
+ font-weight: 700;
27
+ letter-spacing: -.025em;
28
+
29
+ a {
30
+ color: var(--color-text-muted);
31
+
32
+ &:hover { color: var(--color-text); }
33
+ }
34
+ }
35
+
36
+ a {
37
+ color: inherit;
38
+ text-decoration: none;
39
+
40
+ &:hover { text-decoration: underline; }
41
+ }
42
+
43
+ pre {
44
+ margin: 0;
45
+ padding-block: .5rem;
46
+ padding-inline: 1rem;
47
+ max-height: calc(20lh + 1rem);
48
+ font-size: .875rem;
49
+ background: var(--color-bg-hover);
50
+ border-radius: .5rem;
51
+ overflow-x: auto;
52
+ }
53
+
54
+ dl {
55
+ display: grid;
56
+ grid-template-columns: auto 1fr;
57
+ gap: 1rem 2rem;
58
+
59
+ dt {
60
+ font-weight: 600;
61
+ color: var(--color-text);
62
+ }
63
+
64
+ dd { margin: 0; }
65
+ }
66
+ }
67
+
68
+ @layer components {
69
+ article {
70
+ margin-block-end: 1rem;
71
+ padding-block: .5rem;
72
+ padding-inline: .75rem;
73
+ border: 1px solid var(--color-border);
74
+ border-radius: .5rem;
75
+
76
+ &:hover { background: var(--color-bg-hover); }
77
+
78
+ a {
79
+ display: grid;
80
+ grid-template-columns: repeat(4, 1fr);
81
+ gap: 1rem;
82
+
83
+ &:hover { text-decoration: none; }
84
+ }
85
+ }
86
+
87
+ .status {
88
+ display: flex;
89
+ align-items: center;
90
+ column-gap: .375rem;
91
+
92
+ &::before { content: "●"; }
93
+
94
+ &[data-status="pending"]::before { color: oklch(65% 0.15 75); }
95
+
96
+ &[data-status="processed"]::before { color: oklch(65% 0.15 150); }
97
+
98
+ &[data-status="failed"]::before { color: oklch(60% 0.2 25); }
99
+ }
100
+ }
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fuik
4
+ module EventType
5
+ private
6
+
7
+ COMMON_EVENT_TYPE_HEADERS = [
8
+ "X-Github-Event",
9
+ "X-Event-Type",
10
+ "X-Webhook-Event"
11
+ ]
12
+
13
+ COMMON_EVENT_ID_HEADERS = [
14
+ "X-GitHub-Delivery",
15
+ "X-Event-Id",
16
+ "X-Webhook-Id"
17
+ ]
18
+
19
+ def event_type
20
+ from_config("event_type") || from_event_type_headers || from_payload_type || "unknown"
21
+ end
22
+
23
+ def event_id
24
+ from_config("event_id") || from_event_id_headers || payload["id"] || Digest::MD5.hexdigest(request.raw_post)
25
+ end
26
+
27
+ def from_config(key)
28
+ return unless config.present? && config[key].present?
29
+
30
+ case config[key]["source"]
31
+ when "header"
32
+ request.headers[config[key]["key"]]
33
+ when "payload"
34
+ payload[config[key]["key"]]
35
+ end
36
+ end
37
+
38
+ def from_event_type_headers
39
+ COMMON_EVENT_TYPE_HEADERS.lazy.map { |header| request.headers[header] }.find(&:present?)
40
+ end
41
+
42
+ def from_event_id_headers
43
+ COMMON_EVENT_ID_HEADERS.lazy.map { |header| request.headers[header] }.find(&:present?)
44
+ end
45
+
46
+ def from_payload_type
47
+ payload["type"] || payload["event"] || payload["event_type"]
48
+ end
49
+
50
+ def config
51
+ @config ||= begin
52
+ config_path = Rails.root.join("app/webhooks/#{params[:provider]}/config.yml")
53
+
54
+ File.exist?(config_path) ? YAML.load_file(config_path) : nil
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,4 @@
1
+ module Fuik
2
+ class ApplicationController < ActionController::Base
3
+ end
4
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fuik
4
+ class EventsController < Fuik::Engine.config.events_controller_parent.constantize
5
+ layout "fuik/application"
6
+
7
+ def index
8
+ @webhook_events = WebhookEvent.order(created_at: :desc)
9
+ end
10
+
11
+ def show
12
+ @webhook_event = WebhookEvent.find(params[:id])
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fuik
4
+ class WebhooksController < Fuik::Engine.config.webhooks_controller_parent.constantize
5
+ include EventType
6
+
7
+ skip_before_action :verify_authenticity_token
8
+
9
+ def create
10
+ verify_signature!
11
+
12
+ webhook_event = WebhookEvent.create!(
13
+ provider: params[:provider],
14
+ event_id: event_id,
15
+ event_type: event_type,
16
+ body: request.raw_post,
17
+ headers: headers
18
+ )
19
+
20
+ process!(webhook_event)
21
+
22
+ head :ok
23
+ rescue Fuik::InvalidSignature
24
+ head :unauthorized
25
+ rescue ActiveRecord::RecordNotUnique
26
+ head :ok
27
+ end
28
+
29
+ private
30
+
31
+ def verify_signature!
32
+ return unless should_verify?
33
+
34
+ base_class.verify!(request)
35
+ end
36
+
37
+ def headers
38
+ @headers ||= request.headers.env
39
+ .select { |key, _| key.start_with?("HTTP_") }
40
+ .transform_keys { |key| key.sub(/^HTTP_/, "").split("_").map(&:capitalize).join("-") }
41
+ .merge(request.content_type.present? ? {"Content-Type" => request.content_type} : {})
42
+ end
43
+
44
+ def payload
45
+ @payload ||= begin
46
+ return {} if request.raw_post.blank?
47
+
48
+ JSON.parse(request.raw_post)
49
+ rescue JSON::ParserError
50
+ {}
51
+ end
52
+ end
53
+
54
+ def process!(webhook_event)
55
+ event_class = event_class_for(webhook_event.provider, webhook_event.event_type)
56
+ return unless event_class
57
+
58
+ event_class.new(webhook_event).process!
59
+ end
60
+
61
+ def event_class_for(provider, event_type)
62
+ "#{provider.camelize}::#{event_type.tr("./:-", "_").camelize}".safe_constantize
63
+ end
64
+
65
+ def should_verify? = base_class&.respond_to?(:verify!)
66
+
67
+ def base_class
68
+ @base_class ||= "#{params[:provider].camelize}::Base".safe_constantize
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,4 @@
1
+ module Fuik
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,5 @@
1
+ module Fuik
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fuik
4
+ class Event
5
+ def initialize(webhook_event)
6
+ @webhook_event = webhook_event
7
+ end
8
+
9
+ def process!
10
+ raise NotImplementedError, "#{self.class} must implement #process!"
11
+ end
12
+
13
+ def payload
14
+ @webhook_event.payload
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fuik
4
+ class WebhookEvent < ApplicationRecord
5
+ self.table_name = "fuik_webhook_events"
6
+
7
+ enum :status, %w[pending processed failed].index_by(&:itself), default: "pending"
8
+
9
+ validates :provider, presence: true
10
+ validates :event_id, presence: true
11
+
12
+ def payload
13
+ @payload ||= JSON.parse(body)
14
+ rescue JSON::ParserError
15
+ {}
16
+ end
17
+
18
+ def processed!
19
+ update!(status: "processed")
20
+ end
21
+
22
+ def failed!(error)
23
+ update!(status: "failed", error: error.to_s)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,15 @@
1
+ <h1>Webhooks</h1>
2
+
3
+ <% @webhook_events.each do |event| %>
4
+ <article>
5
+ <%= link_to event_path(event) do %>
6
+ <span><%= event.provider %></span>
7
+
8
+ <code><%= event.event_type %></code>
9
+
10
+ <span class="status" data-status="<%= event.status %>"><%= event.status %></span>
11
+
12
+ <time><%= event.created_at.strftime("%Y-%m-%d %H:%M:%S") %></time>
13
+ <% end %>
14
+ </article>
15
+ <% end %>
@@ -0,0 +1,31 @@
1
+ <h1>
2
+ <%= link_to "Webhooks", root_path %> / <%= @webhook_event.event_id %>
3
+ </h1>
4
+
5
+ <dl>
6
+ <dt>Provider</dt>
7
+ <dd><%= @webhook_event.provider %></dd>
8
+
9
+ <dt>Event ID</dt>
10
+ <dd><%= @webhook_event.event_id %></dd>
11
+
12
+ <dt>Type</dt>
13
+ <dd><%= @webhook_event.event_type %></dd>
14
+
15
+ <dt>Status</dt>
16
+ <dd class="status" data-status="<%= @webhook_event.status %>"><%= @webhook_event.status %></dd>
17
+
18
+ <dt>Created</dt>
19
+ <dd><%= @webhook_event.created_at.strftime("%Y-%m-%d %H:%M:%S") %></dd>
20
+
21
+ <dt>Payload</dt>
22
+ <dd><pre><%= JSON.pretty_generate(@webhook_event.payload) %></pre></dd>
23
+
24
+ <dt>Headers</dt>
25
+ <dd><pre><%= JSON.pretty_generate(@webhook_event.headers) %></pre></dd>
26
+
27
+ <% if @webhook_event.error.present? %>
28
+ <dt>Error</dt>
29
+ <dd><%= @webhook_event.error %></dd>
30
+ <% end %>
31
+ </dl>
@@ -0,0 +1,17 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Fuik Admin</title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+
8
+ <%= yield :head %>
9
+
10
+ <%= stylesheet_link_tag "fuik/application", media: "all" %>
11
+ </head>
12
+ <body>
13
+
14
+ <%= yield %>
15
+
16
+ </body>
17
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ Fuik::Engine.routes.draw do
4
+ root to: "events#index"
5
+ resources :events, only: %w[show]
6
+
7
+ post ":provider", to: "webhooks#create"
8
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateWebhookEvents < ActiveRecord::Migration[7.0]
4
+ def change
5
+ create_table :fuik_webhook_events do |t|
6
+ t.string :provider, null: false
7
+ t.string :event_id, null: false
8
+ t.string :event_type, null: false
9
+ t.text :body, null: false
10
+ t.json :headers, default: {}, null: false
11
+ t.string :status, default: "pending", null: false
12
+ t.text :error, null: true
13
+
14
+ t.timestamps
15
+ end
16
+
17
+ add_index :fuik_webhook_events, [:provider, :event_id], unique: true
18
+ add_index :fuik_webhook_events, :status
19
+ add_index :fuik_webhook_events, :created_at
20
+ add_index :fuik_webhook_events, [:provider, :event_type]
21
+ end
22
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fuik
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace Fuik
6
+
7
+ config.webhooks_controller_parent = "ActionController::Base"
8
+ config.events_controller_parent = "ActionController::Base"
9
+ end
10
+ end
@@ -0,0 +1,3 @@
1
+ module Fuik
2
+ VERSION = "0.5.0"
3
+ end
data/lib/fuik.rb ADDED
@@ -0,0 +1,6 @@
1
+ require "fuik/version"
2
+ require "fuik/engine"
3
+
4
+ module Fuik
5
+ class InvalidSignature < StandardError; end
6
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fuik
4
+ module Generators
5
+ class InstallGenerator < Rails::Generators::Base
6
+ source_root File.expand_path("../templates", __FILE__)
7
+
8
+ def create_migrations
9
+ rails_command "railties:install:migrations FROM=fuik", inline: true
10
+ end
11
+
12
+ def add_route
13
+ route 'mount Fuik::Engine => "/webhooks"'
14
+ end
15
+
16
+ def show_readme
17
+ readme "README" if behavior == :invoke
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,24 @@
1
+ ===============================================================================
2
+
3
+ Fuik has been installed! 🎣
4
+
5
+ Next steps:
6
+
7
+ 1. Run migrations:
8
+ rails db:migrate
9
+
10
+ 2. Webhooks can be sent to:
11
+ POST /webhooks/:provider
12
+
13
+ Example: POST /webhooks/stripe
14
+
15
+ 3. (Optional) Generate webhook class for a provider:
16
+ rails generate fuik:provider stripe checkout_session_completed
17
+
18
+ Or start from scratch:
19
+ rails generate fuik:provider custom_provider my_event
20
+
21
+ 4. View received webhooks at:
22
+ GET /webhooks
23
+
24
+ ===============================================================================
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fuik
4
+ module Generators
5
+ class ProviderGenerator < Rails::Generators::NamedBase
6
+ source_root File.expand_path("../templates", __FILE__)
7
+ desc "Generate webhook provider base class and event classes"
8
+
9
+ argument :event_names, type: :array, default: []
10
+
11
+ def create_base_class
12
+ template "base.rb.tt", "app/webhooks/#{file_name}/base.rb"
13
+ end
14
+
15
+ def create_event_classes
16
+ event_names.each do |event_name|
17
+ @event_name = event_name
18
+
19
+ if packaged_event_exists?
20
+ copy_packaged_event
21
+ else
22
+ create_blank_event
23
+ end
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def packaged_event_exists? = File.exist?(packaged_event_template_path)
30
+
31
+ def copy_packaged_event
32
+ template packaged_event_template_path, event_file_path
33
+ end
34
+
35
+ def create_blank_event
36
+ template "event.rb.tt", event_file_path
37
+ end
38
+
39
+ def packaged_event_template_path = File.join(self.class.source_root, "providers", file_name, "#{event_file_name}.rb.tt")
40
+
41
+ def event_file_path = Rails.join("app", "webhooks", file_name, "#{event_file_name}.rb")
42
+
43
+ def event_file_name = @event_name.underscore
44
+
45
+ def event_class_name = @event_name.camelize
46
+
47
+ def provider_module_name = file_name.camelize
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,23 @@
1
+ ===============================================================================
2
+
3
+ Webhook endpoint:
4
+ POST /webhooks/<%= file_name %>
5
+
6
+ Next steps:
7
+
8
+ 1. Configure webhook in <%= provider_module_name %>:
9
+ Set webhook URL to: https://yourdomain.com/webhooks/<%= file_name %>
10
+
11
+ 2. Implement (optional) signature verification in app/webhooks/<%= file_name %>/base.rb:
12
+
13
+ 3. Implement event processing logic in:
14
+ <% event_names.each do |event_name| %>
15
+ - app/webhooks/<%= file_name %>/<%= event_name.underscore %>.rb
16
+ <% end %>
17
+
18
+ View received webhooks at: /webhooks
19
+
20
+
21
+ (🙏 share your created provider with others, by submitting a PR)
22
+
23
+ ===============================================================================
@@ -0,0 +1,8 @@
1
+ module <%= provider_module_name %>
2
+ class Base < Fuik::Event
3
+ # def self.verify!(request)
4
+ # TODO: Implement signature verification
5
+ # raise Fuik::InvalidSignature # only raise when signature is invalid
6
+ # end
7
+ end
8
+ end
@@ -0,0 +1,10 @@
1
+ module <%= provider_module_name %>
2
+ class <%= event_class_name %> < Base
3
+ def process!
4
+ # TODO: Implement webhook processing logic
5
+ # `payload` contains the webhook data
6
+
7
+ @webhook_event.processed!
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,11 @@
1
+ module <%= provider_module_name %>
2
+ class Base < Fuik::Event
3
+ def self.verify!(request)
4
+ secret = Rails.application.credentials.dig(:webhooks, :github, :secret)
5
+ signature = request.headers["X-Hub-Signature-256"]
6
+ expected_signature = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", secret, request.raw_post)
7
+
8
+ raise Fuik::InvalidSignature unless Rack::Utils.secure_compare(signature, expected_signature)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,12 @@
1
+ module <%= provider_module_name %>
2
+ class InstallationCreated < Base
3
+ def process!
4
+ installation_id = payload.dig("installation", "id")
5
+ account = payload.dig("installation", "account", "login")
6
+
7
+ # TODO: Add business logic
8
+
9
+ @webhook_event.processed!
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,14 @@
1
+ module <%= provider_module_name %>
2
+ class Push < Base
3
+ def process!
4
+ # This fires when a pull request is merged (default branch push)
5
+
6
+ repository = payload.dig("repository", "full_name")
7
+ ref = payload["ref"]
8
+
9
+ # TODO: Add business logic
10
+
11
+ @webhook_event.processed!
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,12 @@
1
+ module <%= provider_module_name %>
2
+ class StarCreated < Base
3
+ def process!
4
+ repository = payload.dig("repository", "full_name")
5
+ stargazer = payload.dig("sender", "login")
6
+
7
+ # TODO: Add business logic
8
+
9
+ @webhook_event.processed!
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,19 @@
1
+ module <%= provider_module_name %>
2
+ class Base < Fuik::Event
3
+ def self.verify!(request)
4
+ public_key_base64 = Rails.application.credentials.dig(:webhooks, :mailpace, :public_key)
5
+ signature_base64 = request.headers["X-MailPace-Signature"]
6
+
7
+ verify_key = Ed25519::VerifyKey.new(Base64.strict_decode64(public_key_base64))
8
+ signature = Base64.strict_decode64(signature_base64)
9
+
10
+ raise Fuik::InvalidSignature unless verify_key.verify(signature, request.raw_post)
11
+ rescue Ed25519::VerifyError
12
+ raise Fuik::InvalidSignature
13
+ end
14
+
15
+ private
16
+
17
+ def email = payload["to"]
18
+ end
19
+ end
@@ -0,0 +1,11 @@
1
+ module <%= provider_module_name %>
2
+ class EmailBounced < Base
3
+ def process!
4
+ bounce_type = payload["bounce_type"]
5
+
6
+ # TODO: Add business logic
7
+
8
+ @webhook_event.processed!
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,9 @@
1
+ module <%= provider_module_name %>
2
+ class EmailSpam < Base
3
+ def process!
4
+ # TODO: Add business logic
5
+
6
+ @webhook_event.processed!
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,16 @@
1
+ module <%= provider_module_name %>
2
+ class Base < Fuik::Event
3
+ def self.verify!(request)
4
+ secret = Rails.application.credentials.dig(:webhooks, :stripe, :secret)
5
+ signature = request.headers["Stripe-Signature"]
6
+
7
+ Stripe::Webhook.construct_event(
8
+ request.raw_post,
9
+ signature,
10
+ secret
11
+ )
12
+ rescue JSON::ParserError, Stripe::SignatureVerificationError => error
13
+ raise Fuik::InvalidSignature, error.message
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,12 @@
1
+ module <%= provider_module_name %>
2
+ class <%= event_class_name %> < Base
3
+ def process!
4
+ session_id = payload.dig("data", "object", "id")
5
+
6
+ # TODO: Add business logic
7
+ # session = Stripe::Checkout::Session.retrieve(session_id) # this assumes the Stripe gem is available
8
+
9
+ @webhook_event.processed!
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,11 @@
1
+ module <%= provider_module_name %>
2
+ class <%= event_class_name %> < Base
3
+ def process!
4
+ subscription_id = payload.dig("data", "object", "id")
5
+
6
+ # TODO: Add business logic
7
+
8
+ @webhook_event.processed!
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ module <%= provider_module_name %>
2
+ class <%= event_class_name %> < Base
3
+ def process!
4
+ subscription_id = payload.dig("data", "object", "id")
5
+
6
+ # TODO: Add business logic
7
+
8
+ @webhook_event.processed!
9
+ end
10
+ end
11
+ end
metadata ADDED
@@ -0,0 +1,93 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: fuik
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.5.0
5
+ platform: ruby
6
+ authors:
7
+ - Rails Designer
8
+ bindir: bin
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.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.0
26
+ description: TBD
27
+ email:
28
+ - devs@railsdesigner.com
29
+ executables: []
30
+ extensions: []
31
+ extra_rdoc_files: []
32
+ files:
33
+ - MIT-LICENSE
34
+ - README.md
35
+ - Rakefile
36
+ - app/assets/stylesheets/fuik/application.css
37
+ - app/controllers/concerns/fuik/event_type.rb
38
+ - app/controllers/fuik/application_controller.rb
39
+ - app/controllers/fuik/events_controller.rb
40
+ - app/controllers/fuik/webhooks_controller.rb
41
+ - app/jobs/fuik/application_job.rb
42
+ - app/models/fuik/application_record.rb
43
+ - app/models/fuik/event.rb
44
+ - app/models/fuik/webhook_event.rb
45
+ - app/views/fuik/events/index.html.erb
46
+ - app/views/fuik/events/show.html.erb
47
+ - app/views/layouts/fuik/application.html.erb
48
+ - config/routes.rb
49
+ - db/migrate/20250101000000_create_webhook_events.rb
50
+ - lib/fuik.rb
51
+ - lib/fuik/engine.rb
52
+ - lib/fuik/version.rb
53
+ - lib/generators/fuik/install/install_generator.rb
54
+ - lib/generators/fuik/install/templates/README
55
+ - lib/generators/fuik/provider/provider_generator.rb
56
+ - lib/generators/fuik/provider/templates/README
57
+ - lib/generators/fuik/provider/templates/base.rb.tt
58
+ - lib/generators/fuik/provider/templates/event.rb.tt
59
+ - lib/generators/fuik/provider/templates/github/base.rb.tt
60
+ - lib/generators/fuik/provider/templates/github/installation_created.rb.tt
61
+ - lib/generators/fuik/provider/templates/github/push.rb.tt
62
+ - lib/generators/fuik/provider/templates/github/star_created.rb.tt
63
+ - lib/generators/fuik/provider/templates/mailpace/base.rb.tt
64
+ - lib/generators/fuik/provider/templates/mailpace/email_bounced.rb.tt
65
+ - lib/generators/fuik/provider/templates/mailpace/email_spam.rb.tt
66
+ - lib/generators/fuik/provider/templates/stripe/base.rb.tt
67
+ - lib/generators/fuik/provider/templates/stripe/checkout_session_completed.rb.tt
68
+ - lib/generators/fuik/provider/templates/stripe/customer_subscription_deleted.rb.tt
69
+ - lib/generators/fuik/provider/templates/stripe/customer_subscription_updated.rb.tt
70
+ homepage: https://railsdesigner.com/fuik/
71
+ licenses:
72
+ - MIT
73
+ metadata:
74
+ homepage_uri: https://railsdesigner.com/fuik/
75
+ source_code_uri: https://github.com/Rails-Designer/fuik/
76
+ rdoc_options: []
77
+ require_paths:
78
+ - lib
79
+ required_ruby_version: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: '0'
84
+ required_rubygems_version: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ requirements: []
90
+ rubygems_version: 4.0.0
91
+ specification_version: 4
92
+ summary: TBD
93
+ test_files: []