philiprehberger-webhook_builder 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +17 -0
- data/LICENSE +21 -0
- data/README.md +100 -0
- data/lib/philiprehberger/webhook_builder/client.rb +133 -0
- data/lib/philiprehberger/webhook_builder/delivery.rb +48 -0
- data/lib/philiprehberger/webhook_builder/version.rb +7 -0
- data/lib/philiprehberger/webhook_builder.rb +20 -0
- metadata +57 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f8ace17a470352fbec4b6af92b0e625b346bc6c0580c9e7a53c8ff8ea2c30260
|
|
4
|
+
data.tar.gz: c279d283cb23ac437bd9496b21788de77defe91b68027ae462d47197dd27b0b7
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8fcc581ae60c693c6e5ddaa623fc446e80cb237cad545ae13e142c629903624395c9d189a8228056e63193c15e838c2725137914538bc9e8b1c876e9f36a0b28
|
|
7
|
+
data.tar.gz: ddddb6c9797f16e38dd7797fb4eee25bc265ea903816772b86b92e5a9fca52d50fbbf362d5491ace4368fd2b5152bae635caaeaf693f61ed18ae072408c33f12
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this gem will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-03-22
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Initial release
|
|
14
|
+
- Webhook client with configurable URL, secret, timeout, and max retries
|
|
15
|
+
- HMAC-SHA256 payload signing
|
|
16
|
+
- Automatic retry with exponential backoff on failure
|
|
17
|
+
- Delivery tracking with success status, response code, attempts, and duration
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 philiprehberger
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# philiprehberger-webhook_builder
|
|
2
|
+
|
|
3
|
+
[](https://github.com/philiprehberger/rb-webhook-builder/actions/workflows/ci.yml)
|
|
4
|
+
[](https://rubygems.org/gems/philiprehberger-webhook_builder)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
Webhook delivery client with HMAC signing, retry, and tracking
|
|
8
|
+
|
|
9
|
+
## Requirements
|
|
10
|
+
|
|
11
|
+
- Ruby >= 3.1
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
Add to your Gemfile:
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
gem "philiprehberger-webhook_builder"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Or install directly:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
gem install philiprehberger-webhook_builder
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
require "philiprehberger/webhook_builder"
|
|
31
|
+
|
|
32
|
+
client = Philiprehberger::WebhookBuilder.new(
|
|
33
|
+
url: "https://example.com/webhooks",
|
|
34
|
+
secret: "your-signing-secret"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
delivery = client.deliver(event: "order.created", payload: { id: 123, total: 49.99 })
|
|
38
|
+
delivery.success? # => true
|
|
39
|
+
delivery.response_code # => 200
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Custom Options
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
client = Philiprehberger::WebhookBuilder.new(
|
|
46
|
+
url: "https://api.example.com/hooks",
|
|
47
|
+
secret: "hmac-secret",
|
|
48
|
+
timeout: 10,
|
|
49
|
+
max_retries: 5
|
|
50
|
+
)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Delivery Tracking
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
delivery = client.deliver(event: "user.updated", payload: { id: 42 })
|
|
57
|
+
|
|
58
|
+
delivery.success? # => true/false
|
|
59
|
+
delivery.response_code # => 200
|
|
60
|
+
delivery.attempts # => 1
|
|
61
|
+
delivery.duration # => 0.342 (seconds)
|
|
62
|
+
delivery.response_body # => '{"ok":true}'
|
|
63
|
+
delivery.error # => nil or error message
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### HMAC Signing
|
|
67
|
+
|
|
68
|
+
Every request includes an `X-Webhook-Signature` header with an HMAC-SHA256 hex digest of the JSON body, signed with the configured secret. The `X-Webhook-Event` header contains the event type.
|
|
69
|
+
|
|
70
|
+
## API
|
|
71
|
+
|
|
72
|
+
### `Client`
|
|
73
|
+
|
|
74
|
+
| Method | Description |
|
|
75
|
+
|--------|-------------|
|
|
76
|
+
| `.new(url:, secret:, timeout:, max_retries:)` | Create a webhook client (timeout defaults to 30s, retries to 3) |
|
|
77
|
+
| `#deliver(event:, payload:)` | Deliver a webhook event and return a Delivery |
|
|
78
|
+
|
|
79
|
+
### `Delivery`
|
|
80
|
+
|
|
81
|
+
| Method | Description |
|
|
82
|
+
|--------|-------------|
|
|
83
|
+
| `#success?` | Whether the delivery succeeded (2xx response) |
|
|
84
|
+
| `#response_code` | The HTTP response code |
|
|
85
|
+
| `#attempts` | Number of delivery attempts made |
|
|
86
|
+
| `#duration` | Total duration in seconds across all attempts |
|
|
87
|
+
| `#response_body` | The response body string |
|
|
88
|
+
| `#error` | Error message if delivery failed |
|
|
89
|
+
|
|
90
|
+
## Development
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
bundle install
|
|
94
|
+
bundle exec rspec
|
|
95
|
+
bundle exec rubocop
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## License
|
|
99
|
+
|
|
100
|
+
MIT
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
require "openssl"
|
|
7
|
+
require "time"
|
|
8
|
+
|
|
9
|
+
module Philiprehberger
|
|
10
|
+
module WebhookBuilder
|
|
11
|
+
# Webhook delivery client with HMAC signing, retry, and tracking.
|
|
12
|
+
class Client
|
|
13
|
+
# @return [String] the webhook endpoint URL
|
|
14
|
+
attr_reader :url
|
|
15
|
+
|
|
16
|
+
# @return [Integer] the HTTP timeout in seconds
|
|
17
|
+
attr_reader :timeout
|
|
18
|
+
|
|
19
|
+
# @return [Integer] the maximum number of delivery attempts
|
|
20
|
+
attr_reader :max_retries
|
|
21
|
+
|
|
22
|
+
# Create a new webhook client.
|
|
23
|
+
#
|
|
24
|
+
# @param url [String] the webhook endpoint URL
|
|
25
|
+
# @param secret [String] the HMAC-SHA256 signing secret
|
|
26
|
+
# @param timeout [Integer] HTTP timeout in seconds (default: 30)
|
|
27
|
+
# @param max_retries [Integer] maximum retry attempts on failure (default: 3)
|
|
28
|
+
def initialize(url:, secret:, timeout: 30, max_retries: 3)
|
|
29
|
+
@url = url
|
|
30
|
+
@secret = secret
|
|
31
|
+
@timeout = timeout
|
|
32
|
+
@max_retries = max_retries
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Deliver a webhook event.
|
|
36
|
+
#
|
|
37
|
+
# @param event [String] the event type (e.g., "order.created")
|
|
38
|
+
# @param payload [Hash] the event payload
|
|
39
|
+
# @return [Delivery] the delivery result
|
|
40
|
+
def deliver(event:, payload:)
|
|
41
|
+
body = JSON.generate({ event: event, payload: payload, timestamp: Time.now.utc.iso8601 })
|
|
42
|
+
signature = sign(body)
|
|
43
|
+
|
|
44
|
+
attempts = 0
|
|
45
|
+
start_time = monotonic_now
|
|
46
|
+
last_response_code = nil
|
|
47
|
+
last_response_body = nil
|
|
48
|
+
last_error = nil
|
|
49
|
+
|
|
50
|
+
loop do
|
|
51
|
+
attempts += 1
|
|
52
|
+
begin
|
|
53
|
+
response = send_request(body, signature, event)
|
|
54
|
+
last_response_code = response.code.to_i
|
|
55
|
+
last_response_body = response.body
|
|
56
|
+
|
|
57
|
+
if last_response_code >= 200 && last_response_code < 300
|
|
58
|
+
return Delivery.new(
|
|
59
|
+
success: true,
|
|
60
|
+
response_code: last_response_code,
|
|
61
|
+
attempts: attempts,
|
|
62
|
+
duration: monotonic_now - start_time,
|
|
63
|
+
response_body: last_response_body
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
last_error = "HTTP #{last_response_code}"
|
|
68
|
+
rescue StandardError => e
|
|
69
|
+
last_error = e.message
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
break if attempts > @max_retries
|
|
73
|
+
|
|
74
|
+
sleep(backoff_delay(attempts))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
Delivery.new(
|
|
78
|
+
success: false,
|
|
79
|
+
response_code: last_response_code,
|
|
80
|
+
attempts: attempts,
|
|
81
|
+
duration: monotonic_now - start_time,
|
|
82
|
+
response_body: last_response_body,
|
|
83
|
+
error: last_error
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
# Sign the request body with HMAC-SHA256.
|
|
90
|
+
#
|
|
91
|
+
# @param body [String] the JSON body
|
|
92
|
+
# @return [String] the hex-encoded HMAC signature
|
|
93
|
+
def sign(body)
|
|
94
|
+
OpenSSL::HMAC.hexdigest("SHA256", @secret, body)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Send the HTTP POST request.
|
|
98
|
+
#
|
|
99
|
+
# @param body [String] the JSON body
|
|
100
|
+
# @param signature [String] the HMAC signature
|
|
101
|
+
# @param event [String] the event type
|
|
102
|
+
# @return [Net::HTTPResponse]
|
|
103
|
+
def send_request(body, signature, event)
|
|
104
|
+
uri = URI.parse(@url)
|
|
105
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
106
|
+
http.use_ssl = uri.scheme == "https"
|
|
107
|
+
http.open_timeout = @timeout
|
|
108
|
+
http.read_timeout = @timeout
|
|
109
|
+
|
|
110
|
+
request = Net::HTTP::Post.new(uri.request_uri)
|
|
111
|
+
request["Content-Type"] = "application/json"
|
|
112
|
+
request["X-Webhook-Signature"] = signature
|
|
113
|
+
request["X-Webhook-Event"] = event
|
|
114
|
+
request["User-Agent"] = "philiprehberger-webhook_builder/#{VERSION}"
|
|
115
|
+
request.body = body
|
|
116
|
+
|
|
117
|
+
http.request(request)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Calculate exponential backoff delay.
|
|
121
|
+
#
|
|
122
|
+
# @param attempt [Integer] the current attempt number
|
|
123
|
+
# @return [Float] delay in seconds
|
|
124
|
+
def backoff_delay(attempt)
|
|
125
|
+
[2**(attempt - 1), 30].min
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def monotonic_now
|
|
129
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Philiprehberger
|
|
4
|
+
module WebhookBuilder
|
|
5
|
+
# Represents the result of a webhook delivery attempt.
|
|
6
|
+
class Delivery
|
|
7
|
+
# @return [Boolean] whether the delivery succeeded (2xx response)
|
|
8
|
+
attr_reader :success
|
|
9
|
+
|
|
10
|
+
# @return [Integer, nil] the HTTP response code
|
|
11
|
+
attr_reader :response_code
|
|
12
|
+
|
|
13
|
+
# @return [Integer] the number of delivery attempts made
|
|
14
|
+
attr_reader :attempts
|
|
15
|
+
|
|
16
|
+
# @return [Float] the total duration in seconds across all attempts
|
|
17
|
+
attr_reader :duration
|
|
18
|
+
|
|
19
|
+
# @return [String, nil] the response body
|
|
20
|
+
attr_reader :response_body
|
|
21
|
+
|
|
22
|
+
# @return [String, nil] the error message if delivery failed
|
|
23
|
+
attr_reader :error
|
|
24
|
+
|
|
25
|
+
# @param success [Boolean]
|
|
26
|
+
# @param response_code [Integer, nil]
|
|
27
|
+
# @param attempts [Integer]
|
|
28
|
+
# @param duration [Float]
|
|
29
|
+
# @param response_body [String, nil]
|
|
30
|
+
# @param error [String, nil]
|
|
31
|
+
def initialize(success:, response_code:, attempts:, duration:, response_body: nil, error: nil)
|
|
32
|
+
@success = success
|
|
33
|
+
@response_code = response_code
|
|
34
|
+
@attempts = attempts
|
|
35
|
+
@duration = duration
|
|
36
|
+
@response_body = response_body
|
|
37
|
+
@error = error
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Whether the delivery was successful.
|
|
41
|
+
#
|
|
42
|
+
# @return [Boolean]
|
|
43
|
+
def success?
|
|
44
|
+
@success
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Philiprehberger
|
|
4
|
+
module WebhookBuilder
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Convenience method to create a new webhook client.
|
|
8
|
+
#
|
|
9
|
+
# @param options [Hash] options passed to {Client#initialize}
|
|
10
|
+
# @return [Client] a new webhook client
|
|
11
|
+
# @see Client#initialize
|
|
12
|
+
def self.new(**options)
|
|
13
|
+
Client.new(**options)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
require_relative "webhook_builder/version"
|
|
19
|
+
require_relative "webhook_builder/delivery"
|
|
20
|
+
require_relative "webhook_builder/client"
|
metadata
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: philiprehberger-webhook_builder
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Philip Rehberger
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-03-22 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: A webhook delivery client that signs payloads with HMAC-SHA256, retries
|
|
14
|
+
failed deliveries with exponential backoff, and tracks delivery status including
|
|
15
|
+
response codes, attempts, and duration.
|
|
16
|
+
email:
|
|
17
|
+
- me@philiprehberger.com
|
|
18
|
+
executables: []
|
|
19
|
+
extensions: []
|
|
20
|
+
extra_rdoc_files: []
|
|
21
|
+
files:
|
|
22
|
+
- CHANGELOG.md
|
|
23
|
+
- LICENSE
|
|
24
|
+
- README.md
|
|
25
|
+
- lib/philiprehberger/webhook_builder.rb
|
|
26
|
+
- lib/philiprehberger/webhook_builder/client.rb
|
|
27
|
+
- lib/philiprehberger/webhook_builder/delivery.rb
|
|
28
|
+
- lib/philiprehberger/webhook_builder/version.rb
|
|
29
|
+
homepage: https://github.com/philiprehberger/rb-webhook-builder
|
|
30
|
+
licenses:
|
|
31
|
+
- MIT
|
|
32
|
+
metadata:
|
|
33
|
+
homepage_uri: https://github.com/philiprehberger/rb-webhook-builder
|
|
34
|
+
source_code_uri: https://github.com/philiprehberger/rb-webhook-builder
|
|
35
|
+
changelog_uri: https://github.com/philiprehberger/rb-webhook-builder/blob/main/CHANGELOG.md
|
|
36
|
+
bug_tracker_uri: https://github.com/philiprehberger/rb-webhook-builder/issues
|
|
37
|
+
rubygems_mfa_required: 'true'
|
|
38
|
+
post_install_message:
|
|
39
|
+
rdoc_options: []
|
|
40
|
+
require_paths:
|
|
41
|
+
- lib
|
|
42
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: 3.1.0
|
|
47
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
48
|
+
requirements:
|
|
49
|
+
- - ">="
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '0'
|
|
52
|
+
requirements: []
|
|
53
|
+
rubygems_version: 3.5.22
|
|
54
|
+
signing_key:
|
|
55
|
+
specification_version: 4
|
|
56
|
+
summary: Webhook delivery client with HMAC signing, retry, and tracking
|
|
57
|
+
test_files: []
|