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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +165 -0
- data/Rakefile +17 -0
- data/app/assets/stylesheets/fuik/application.css +100 -0
- data/app/controllers/concerns/fuik/event_type.rb +58 -0
- data/app/controllers/fuik/application_controller.rb +4 -0
- data/app/controllers/fuik/events_controller.rb +15 -0
- data/app/controllers/fuik/webhooks_controller.rb +71 -0
- data/app/jobs/fuik/application_job.rb +4 -0
- data/app/models/fuik/application_record.rb +5 -0
- data/app/models/fuik/event.rb +17 -0
- data/app/models/fuik/webhook_event.rb +26 -0
- data/app/views/fuik/events/index.html.erb +15 -0
- data/app/views/fuik/events/show.html.erb +31 -0
- data/app/views/layouts/fuik/application.html.erb +17 -0
- data/config/routes.rb +8 -0
- data/db/migrate/20250101000000_create_webhook_events.rb +22 -0
- data/lib/fuik/engine.rb +10 -0
- data/lib/fuik/version.rb +3 -0
- data/lib/fuik.rb +6 -0
- data/lib/generators/fuik/install/install_generator.rb +21 -0
- data/lib/generators/fuik/install/templates/README +24 -0
- data/lib/generators/fuik/provider/provider_generator.rb +50 -0
- data/lib/generators/fuik/provider/templates/README +23 -0
- data/lib/generators/fuik/provider/templates/base.rb.tt +8 -0
- data/lib/generators/fuik/provider/templates/event.rb.tt +10 -0
- data/lib/generators/fuik/provider/templates/github/base.rb.tt +11 -0
- data/lib/generators/fuik/provider/templates/github/installation_created.rb.tt +12 -0
- data/lib/generators/fuik/provider/templates/github/push.rb.tt +14 -0
- data/lib/generators/fuik/provider/templates/github/star_created.rb.tt +12 -0
- data/lib/generators/fuik/provider/templates/mailpace/base.rb.tt +19 -0
- data/lib/generators/fuik/provider/templates/mailpace/email_bounced.rb.tt +11 -0
- data/lib/generators/fuik/provider/templates/mailpace/email_spam.rb.tt +9 -0
- data/lib/generators/fuik/provider/templates/stripe/base.rb.tt +16 -0
- data/lib/generators/fuik/provider/templates/stripe/checkout_session_completed.rb.tt +12 -0
- data/lib/generators/fuik/provider/templates/stripe/customer_subscription_deleted.rb.tt +11 -0
- data/lib/generators/fuik/provider/templates/stripe/customer_subscription_updated.rb.tt +11 -0
- 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,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,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,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
|
data/lib/fuik/engine.rb
ADDED
data/lib/fuik/version.rb
ADDED
data/lib/fuik.rb
ADDED
|
@@ -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,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,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
|
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: []
|