hookbridge 1.0.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 +27 -0
- data/LICENSE +21 -0
- data/README.md +254 -0
- data/lib/hookbridge/client.rb +299 -0
- data/lib/hookbridge/errors.rb +74 -0
- data/lib/hookbridge/types.rb +355 -0
- data/lib/hookbridge/version.rb +5 -0
- data/lib/hookbridge.rb +19 -0
- metadata +186 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 2b629a213046fab3d1fb2ea9eaffcf09de4cb12315b121865ced6e369d536d0e
|
|
4
|
+
data.tar.gz: b3e7b81b1bf0464f6f127eb065e572f3372159902d1931cc84e02cde7321abd4
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 4240c5f2e367dedf09c0af5c628cc29e5f7522ef27c81b23fc9cb8e45fddd699a4c4d04490a132ff702ef8ea4b393e996ad627d7e906370157ec012e0ed21af5
|
|
7
|
+
data.tar.gz: 9868337d7c0b46efcb8ab17b22833c40d5a8f86c348c2ba3430be617737d9309ef25d3bb445b7265a7bb6deed294844a388f62ee3febee42bb52ea38509c11fb
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project 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
|
+
## [1.0.0] - 2024-01-15
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Initial release of the HookBridge Ruby SDK
|
|
13
|
+
- `send` - Send webhooks with guaranteed delivery
|
|
14
|
+
- `get_message` - Get detailed message status
|
|
15
|
+
- `replay` - Replay failed messages
|
|
16
|
+
- `cancel_retry` - Cancel pending retries
|
|
17
|
+
- `retry_now` - Trigger immediate retry
|
|
18
|
+
- `get_logs` - Query delivery logs with filtering
|
|
19
|
+
- `get_metrics` - Get aggregated delivery metrics
|
|
20
|
+
- `get_dlq_messages` - List Dead Letter Queue messages
|
|
21
|
+
- `replay_from_dlq` - Replay messages from DLQ
|
|
22
|
+
- `list_api_keys` - List project API keys
|
|
23
|
+
- `create_api_key` - Create new API keys
|
|
24
|
+
- `delete_api_key` - Delete API keys
|
|
25
|
+
- Comprehensive error handling with specific exception types
|
|
26
|
+
- Automatic retries with exponential backoff for transient failures
|
|
27
|
+
- Full type definitions for all API responses
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 HookBridge
|
|
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,254 @@
|
|
|
1
|
+
# HookBridge Ruby SDK
|
|
2
|
+
|
|
3
|
+
Official Ruby client library for the [HookBridge](https://hookbridge.io) API. Send webhooks with guaranteed delivery, automatic retries, and comprehensive delivery tracking.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add this line to your application's Gemfile:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem 'hookbridge'
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
And then execute:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bundle install
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Or install it yourself:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
gem install hookbridge
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quick Start
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
require 'hookbridge'
|
|
29
|
+
|
|
30
|
+
# Initialize the client
|
|
31
|
+
client = HookBridge.new(api_key: 'hb_live_xxxxxxxxxxxxxxxxxxxx')
|
|
32
|
+
|
|
33
|
+
# Send a webhook
|
|
34
|
+
response = client.send(
|
|
35
|
+
endpoint: 'my-endpoint',
|
|
36
|
+
payload: { event: 'user.created', data: { id: 123 } }
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
puts "Message queued: #{response.message_id}"
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Configuration
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
client = HookBridge.new(
|
|
46
|
+
api_key: 'hb_live_xxxxxxxxxxxxxxxxxxxx',
|
|
47
|
+
base_url: 'https://api.hookbridge.io', # optional, can also use HOOKBRIDGE_BASE_URL env var
|
|
48
|
+
timeout: 30, # request timeout in seconds (default: 30)
|
|
49
|
+
retries: 3 # number of retries for failed requests (default: 3)
|
|
50
|
+
)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Usage
|
|
54
|
+
|
|
55
|
+
### Sending Webhooks
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
# Basic send
|
|
59
|
+
response = client.send(
|
|
60
|
+
endpoint: 'my-endpoint',
|
|
61
|
+
payload: { event: 'order.completed', order_id: 456 }
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# With idempotency key to prevent duplicates
|
|
65
|
+
response = client.send(
|
|
66
|
+
endpoint: 'my-endpoint',
|
|
67
|
+
payload: { event: 'payment.processed' },
|
|
68
|
+
idempotency_key: 'payment_789'
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# With custom content type
|
|
72
|
+
response = client.send(
|
|
73
|
+
endpoint: 'my-endpoint',
|
|
74
|
+
payload: '<xml>data</xml>',
|
|
75
|
+
content_type: 'application/xml'
|
|
76
|
+
)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Checking Message Status
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
message = client.get_message('msg_01234567890abcdef')
|
|
83
|
+
|
|
84
|
+
puts message.status # => "succeeded"
|
|
85
|
+
puts message.attempt_count # => 1
|
|
86
|
+
puts message.response_status # => 200
|
|
87
|
+
puts message.response_latency_ms
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Replaying Messages
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
# Replay a failed message
|
|
94
|
+
response = client.replay('msg_01234567890abcdef')
|
|
95
|
+
puts "Replayed, attempt #{response.attempt_count}"
|
|
96
|
+
|
|
97
|
+
# Trigger immediate retry for a pending message
|
|
98
|
+
client.retry_now('msg_01234567890abcdef')
|
|
99
|
+
|
|
100
|
+
# Cancel a pending retry
|
|
101
|
+
client.cancel_retry('msg_01234567890abcdef')
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Querying Delivery Logs
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
# Get recent logs
|
|
108
|
+
logs = client.get_logs
|
|
109
|
+
|
|
110
|
+
logs.messages.each do |msg|
|
|
111
|
+
puts "#{msg.message_id}: #{msg.status}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Filter by status
|
|
115
|
+
logs = client.get_logs(status: HookBridge::MessageStatus::FAILED_PERMANENT)
|
|
116
|
+
|
|
117
|
+
# Filter by time range
|
|
118
|
+
logs = client.get_logs(
|
|
119
|
+
start_time: Time.now - 3600, # last hour
|
|
120
|
+
end_time: Time.now,
|
|
121
|
+
limit: 50
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Pagination
|
|
125
|
+
logs = client.get_logs(limit: 20)
|
|
126
|
+
if logs.has_more
|
|
127
|
+
next_page = client.get_logs(cursor: logs.next_cursor)
|
|
128
|
+
end
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Getting Metrics
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
# Get 24-hour metrics (default)
|
|
135
|
+
metrics = client.get_metrics
|
|
136
|
+
|
|
137
|
+
puts "Total: #{metrics.total_messages}"
|
|
138
|
+
puts "Success rate: #{(metrics.success_rate * 100).round(1)}%"
|
|
139
|
+
puts "Avg latency: #{metrics.avg_latency_ms}ms"
|
|
140
|
+
|
|
141
|
+
# Different time windows
|
|
142
|
+
metrics_1h = client.get_metrics(window: HookBridge::MetricsWindow::ONE_HOUR)
|
|
143
|
+
metrics_7d = client.get_metrics(window: HookBridge::MetricsWindow::SEVEN_DAYS)
|
|
144
|
+
metrics_30d = client.get_metrics(window: HookBridge::MetricsWindow::THIRTY_DAYS)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Dead Letter Queue
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
# List failed messages
|
|
151
|
+
dlq = client.get_dlq_messages
|
|
152
|
+
|
|
153
|
+
dlq.messages.each do |msg|
|
|
154
|
+
puts "#{msg.message_id} failed: #{msg.reason}"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Replay from DLQ
|
|
158
|
+
response = client.replay_from_dlq('msg_01234567890abcdef')
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### API Key Management
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
# List all API keys
|
|
165
|
+
keys = client.list_api_keys
|
|
166
|
+
keys.keys.each do |key|
|
|
167
|
+
puts "#{key.name}: #{key.prefix}... (#{key.mode})"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Create a new key
|
|
171
|
+
new_key = client.create_api_key(name: 'Production Server', mode: HookBridge::APIKeyMode::LIVE)
|
|
172
|
+
puts "Save this key: #{new_key.key}" # Only shown once!
|
|
173
|
+
|
|
174
|
+
# Delete a key
|
|
175
|
+
client.delete_api_key('key_01234567890abcdef')
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Error Handling
|
|
179
|
+
|
|
180
|
+
The SDK raises specific exceptions for different error conditions:
|
|
181
|
+
|
|
182
|
+
```ruby
|
|
183
|
+
begin
|
|
184
|
+
client.send(endpoint: 'my-endpoint', payload: data)
|
|
185
|
+
rescue HookBridge::AuthenticationError => e
|
|
186
|
+
# Invalid or missing API key (401)
|
|
187
|
+
puts "Auth failed: #{e.message}"
|
|
188
|
+
rescue HookBridge::ValidationError => e
|
|
189
|
+
# Invalid request parameters (400)
|
|
190
|
+
puts "Validation failed: #{e.message}"
|
|
191
|
+
rescue HookBridge::NotFoundError => e
|
|
192
|
+
# Resource not found (404)
|
|
193
|
+
puts "Not found: #{e.message}"
|
|
194
|
+
rescue HookBridge::RateLimitError => e
|
|
195
|
+
# Rate limit exceeded (429)
|
|
196
|
+
puts "Rate limited, retry after #{e.retry_after} seconds"
|
|
197
|
+
rescue HookBridge::IdempotencyError => e
|
|
198
|
+
# Idempotency key conflict (409)
|
|
199
|
+
puts "Duplicate request: #{e.message}"
|
|
200
|
+
rescue HookBridge::ReplayLimitError => e
|
|
201
|
+
# Replay limit exceeded (409)
|
|
202
|
+
puts "Replay limit reached: #{e.message}"
|
|
203
|
+
rescue HookBridge::TimeoutError => e
|
|
204
|
+
# Request timed out
|
|
205
|
+
puts "Timeout: #{e.message}"
|
|
206
|
+
rescue HookBridge::NetworkError => e
|
|
207
|
+
# Network/connection error
|
|
208
|
+
puts "Network error: #{e.message}"
|
|
209
|
+
rescue HookBridge::Error => e
|
|
210
|
+
# Other API errors
|
|
211
|
+
puts "Error: #{e.message} (code: #{e.code}, request_id: #{e.request_id})"
|
|
212
|
+
end
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## Message Statuses
|
|
216
|
+
|
|
217
|
+
Messages can be in one of these statuses:
|
|
218
|
+
|
|
219
|
+
| Status | Description |
|
|
220
|
+
|--------|-------------|
|
|
221
|
+
| `queued` | Message is queued for delivery |
|
|
222
|
+
| `delivering` | Delivery attempt in progress |
|
|
223
|
+
| `succeeded` | Successfully delivered |
|
|
224
|
+
| `pending_retry` | Failed, will retry automatically |
|
|
225
|
+
| `failed_permanent` | Failed permanently (in DLQ) |
|
|
226
|
+
|
|
227
|
+
Use the constants:
|
|
228
|
+
|
|
229
|
+
```ruby
|
|
230
|
+
HookBridge::MessageStatus::QUEUED
|
|
231
|
+
HookBridge::MessageStatus::DELIVERING
|
|
232
|
+
HookBridge::MessageStatus::SUCCEEDED
|
|
233
|
+
HookBridge::MessageStatus::PENDING_RETRY
|
|
234
|
+
HookBridge::MessageStatus::FAILED_PERMANENT
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
## Requirements
|
|
238
|
+
|
|
239
|
+
- Ruby 3.0 or higher
|
|
240
|
+
- Faraday 2.x
|
|
241
|
+
|
|
242
|
+
## Development
|
|
243
|
+
|
|
244
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests.
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
bundle install
|
|
248
|
+
bundle exec rspec
|
|
249
|
+
bundle exec rubocop
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## License
|
|
253
|
+
|
|
254
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "faraday/retry"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module HookBridge
|
|
8
|
+
# Main client for interacting with the HookBridge API
|
|
9
|
+
class Client
|
|
10
|
+
DEFAULT_BASE_URL = "https://api.hookbridge.io"
|
|
11
|
+
DEFAULT_TIMEOUT = 30
|
|
12
|
+
DEFAULT_RETRIES = 3
|
|
13
|
+
|
|
14
|
+
attr_reader :api_key, :base_url, :timeout, :retries
|
|
15
|
+
|
|
16
|
+
# Initialize a new HookBridge client
|
|
17
|
+
#
|
|
18
|
+
# @param api_key [String] Your HookBridge API key (starts with hb_live_ or hb_test_)
|
|
19
|
+
# @param base_url [String] API base URL (defaults to https://api.hookbridge.io)
|
|
20
|
+
# @param timeout [Integer] Request timeout in seconds (defaults to 30)
|
|
21
|
+
# @param retries [Integer] Number of retries for failed requests (defaults to 3)
|
|
22
|
+
def initialize(api_key:, base_url: nil, timeout: DEFAULT_TIMEOUT, retries: DEFAULT_RETRIES)
|
|
23
|
+
raise ValidationError, "API key is required" if api_key.nil? || api_key.empty?
|
|
24
|
+
|
|
25
|
+
@api_key = api_key
|
|
26
|
+
@base_url = (base_url || ENV.fetch("HOOKBRIDGE_BASE_URL", DEFAULT_BASE_URL)).chomp("/")
|
|
27
|
+
@timeout = timeout
|
|
28
|
+
@retries = retries
|
|
29
|
+
@connection = build_connection
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Send a webhook for guaranteed delivery
|
|
33
|
+
#
|
|
34
|
+
# @param endpoint [String] The registered endpoint identifier
|
|
35
|
+
# @param payload [Hash, String] The webhook payload
|
|
36
|
+
# @param idempotency_key [String, nil] Optional idempotency key to prevent duplicates
|
|
37
|
+
# @param content_type [String] Content type of the payload (defaults to application/json)
|
|
38
|
+
# @return [SendResponse] The send response with message_id and status
|
|
39
|
+
def send(endpoint:, payload:, idempotency_key: nil, content_type: "application/json")
|
|
40
|
+
body = {
|
|
41
|
+
endpoint: endpoint,
|
|
42
|
+
payload: payload,
|
|
43
|
+
content_type: content_type
|
|
44
|
+
}
|
|
45
|
+
body[:idempotency_key] = idempotency_key if idempotency_key
|
|
46
|
+
|
|
47
|
+
data = request(:post, "/v1/webhooks/send", body)
|
|
48
|
+
SendResponse.new(data)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Get detailed status for a specific message
|
|
52
|
+
#
|
|
53
|
+
# @param message_id [String] The message ID (UUIDv7)
|
|
54
|
+
# @return [Message] The message details
|
|
55
|
+
def get_message(message_id)
|
|
56
|
+
data = request(:get, "/v1/messages/#{message_id}")
|
|
57
|
+
Message.new(data)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Manually replay a failed message
|
|
61
|
+
#
|
|
62
|
+
# @param message_id [String] The message ID to replay
|
|
63
|
+
# @return [ReplayResponse] The replay response
|
|
64
|
+
def replay(message_id)
|
|
65
|
+
data = request(:post, "/v1/messages/#{message_id}/replay")
|
|
66
|
+
ReplayResponse.new(data)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Cancel a pending retry
|
|
70
|
+
#
|
|
71
|
+
# @param message_id [String] The message ID
|
|
72
|
+
# @return [Message] The updated message
|
|
73
|
+
def cancel_retry(message_id)
|
|
74
|
+
data = request(:post, "/v1/messages/#{message_id}/cancel")
|
|
75
|
+
Message.new(data)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Trigger immediate retry for a pending message
|
|
79
|
+
#
|
|
80
|
+
# @param message_id [String] The message ID
|
|
81
|
+
# @return [Message] The updated message
|
|
82
|
+
def retry_now(message_id)
|
|
83
|
+
data = request(:post, "/v1/messages/#{message_id}/retry-now")
|
|
84
|
+
Message.new(data)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Query delivery logs with optional filtering
|
|
88
|
+
#
|
|
89
|
+
# @param status [String, nil] Filter by message status
|
|
90
|
+
# @param start_time [Time, String, nil] Filter by start time
|
|
91
|
+
# @param end_time [Time, String, nil] Filter by end time
|
|
92
|
+
# @param limit [Integer, nil] Maximum number of results (default 50, max 100)
|
|
93
|
+
# @param cursor [String, nil] Pagination cursor from previous response
|
|
94
|
+
# @return [LogsResponse] Paginated list of message summaries
|
|
95
|
+
def get_logs(status: nil, start_time: nil, end_time: nil, limit: nil, cursor: nil)
|
|
96
|
+
params = {}
|
|
97
|
+
params[:status] = status if status
|
|
98
|
+
params[:start_time] = format_time(start_time) if start_time
|
|
99
|
+
params[:end_time] = format_time(end_time) if end_time
|
|
100
|
+
params[:limit] = limit if limit
|
|
101
|
+
params[:cursor] = cursor if cursor
|
|
102
|
+
|
|
103
|
+
data = request(:get, "/v1/logs", nil, params)
|
|
104
|
+
LogsResponse.new(data)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Retrieve aggregated delivery metrics
|
|
108
|
+
#
|
|
109
|
+
# @param window [String] Time window: "1h", "24h", "7d", or "30d"
|
|
110
|
+
# @return [Metrics] The delivery metrics
|
|
111
|
+
def get_metrics(window: MetricsWindow::TWENTY_FOUR_HOURS)
|
|
112
|
+
data = request(:get, "/v1/metrics", nil, { window: window })
|
|
113
|
+
Metrics.new(data)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# List messages in the Dead Letter Queue
|
|
117
|
+
#
|
|
118
|
+
# @param limit [Integer, nil] Maximum number of results
|
|
119
|
+
# @param cursor [String, nil] Pagination cursor
|
|
120
|
+
# @return [DLQResponse] Paginated list of DLQ messages
|
|
121
|
+
def get_dlq_messages(limit: nil, cursor: nil)
|
|
122
|
+
params = {}
|
|
123
|
+
params[:limit] = limit if limit
|
|
124
|
+
params[:cursor] = cursor if cursor
|
|
125
|
+
|
|
126
|
+
data = request(:get, "/v1/dlq/messages", nil, params)
|
|
127
|
+
DLQResponse.new(data)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Replay a message from the Dead Letter Queue
|
|
131
|
+
#
|
|
132
|
+
# @param message_id [String] The message ID to replay
|
|
133
|
+
# @return [ReplayResponse] The replay response
|
|
134
|
+
def replay_from_dlq(message_id)
|
|
135
|
+
data = request(:post, "/v1/dlq/replay/#{message_id}")
|
|
136
|
+
ReplayResponse.new(data)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# List all API keys for the project
|
|
140
|
+
#
|
|
141
|
+
# @return [APIKeysResponse] List of API keys
|
|
142
|
+
def list_api_keys
|
|
143
|
+
data = request(:get, "/v1/api-keys")
|
|
144
|
+
APIKeysResponse.new(data)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Create a new API key
|
|
148
|
+
#
|
|
149
|
+
# @param name [String] Name for the API key
|
|
150
|
+
# @param mode [String] Key mode: "live" or "test"
|
|
151
|
+
# @return [APIKeyCreated] The created API key (includes full key, only shown once)
|
|
152
|
+
def create_api_key(label: nil, mode: APIKeyMode::LIVE)
|
|
153
|
+
body = { mode: mode }
|
|
154
|
+
body[:label] = label if label
|
|
155
|
+
data = request(:post, "/v1/api-keys", body)
|
|
156
|
+
APIKeyCreated.new(data)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Delete an API key
|
|
160
|
+
#
|
|
161
|
+
# @param key_id [String] The API key ID to delete
|
|
162
|
+
# @return [Boolean] true if successful
|
|
163
|
+
def delete_api_key(key_id)
|
|
164
|
+
request(:delete, "/v1/api-keys/#{key_id}")
|
|
165
|
+
true
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
private
|
|
169
|
+
|
|
170
|
+
def build_connection
|
|
171
|
+
Faraday.new(url: @base_url) do |conn|
|
|
172
|
+
conn.request :retry,
|
|
173
|
+
max: @retries,
|
|
174
|
+
interval: 0.1,
|
|
175
|
+
interval_randomness: 0.5,
|
|
176
|
+
backoff_factor: 2,
|
|
177
|
+
retry_statuses: [500, 502, 503, 504],
|
|
178
|
+
methods: %i[get post put delete],
|
|
179
|
+
retry_block: lambda { |env, _options, retries, exception|
|
|
180
|
+
# Don't retry client errors
|
|
181
|
+
return false if env.status && env.status < 500
|
|
182
|
+
|
|
183
|
+
true
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
conn.options.timeout = @timeout
|
|
187
|
+
conn.options.open_timeout = 10
|
|
188
|
+
|
|
189
|
+
conn.headers["Authorization"] = "Bearer #{@api_key}"
|
|
190
|
+
conn.headers["Content-Type"] = "application/json"
|
|
191
|
+
conn.headers["Accept"] = "application/json"
|
|
192
|
+
conn.headers["User-Agent"] = "hookbridge-ruby/#{VERSION}"
|
|
193
|
+
|
|
194
|
+
conn.adapter Faraday.default_adapter
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def request(method, path, body = nil, params = nil)
|
|
199
|
+
response = @connection.run_request(method, path, body&.to_json, nil) do |req|
|
|
200
|
+
req.params.update(params) if params
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
handle_response(response)
|
|
204
|
+
rescue Faraday::TimeoutError => e
|
|
205
|
+
raise TimeoutError.new("Request timed out: #{e.message}")
|
|
206
|
+
rescue Faraday::ConnectionFailed => e
|
|
207
|
+
raise NetworkError.new("Connection failed: #{e.message}")
|
|
208
|
+
rescue Faraday::Error => e
|
|
209
|
+
raise NetworkError.new("Network error: #{e.message}")
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def handle_response(response)
|
|
213
|
+
request_id = response.headers["x-request-id"]
|
|
214
|
+
|
|
215
|
+
case response.status
|
|
216
|
+
when 200..299
|
|
217
|
+
return nil if response.body.nil? || response.body.empty?
|
|
218
|
+
|
|
219
|
+
parsed = JSON.parse(response.body)
|
|
220
|
+
# API wraps responses in { data: ..., meta: ... }
|
|
221
|
+
# Return data field if present, otherwise return parsed response
|
|
222
|
+
if parsed.is_a?(Hash) && parsed.key?("data")
|
|
223
|
+
# For paginated responses, include meta info
|
|
224
|
+
if parsed["meta"]&.key?("has_more") || parsed["meta"]&.key?("next_cursor")
|
|
225
|
+
{
|
|
226
|
+
"data" => parsed["data"],
|
|
227
|
+
"has_more" => parsed.dig("meta", "has_more"),
|
|
228
|
+
"next_cursor" => parsed.dig("meta", "next_cursor")
|
|
229
|
+
}
|
|
230
|
+
else
|
|
231
|
+
parsed["data"]
|
|
232
|
+
end
|
|
233
|
+
else
|
|
234
|
+
parsed
|
|
235
|
+
end
|
|
236
|
+
when 400
|
|
237
|
+
error_data = parse_error(response.body)
|
|
238
|
+
raise ValidationError.new(error_data[:message], request_id: request_id)
|
|
239
|
+
when 401
|
|
240
|
+
error_data = parse_error(response.body)
|
|
241
|
+
raise AuthenticationError.new(error_data[:message], request_id: request_id)
|
|
242
|
+
when 404
|
|
243
|
+
error_data = parse_error(response.body)
|
|
244
|
+
raise NotFoundError.new(error_data[:message], request_id: request_id)
|
|
245
|
+
when 409
|
|
246
|
+
error_data = parse_error(response.body)
|
|
247
|
+
code = error_data[:code]
|
|
248
|
+
if code == "REPLAY_LIMIT_EXCEEDED"
|
|
249
|
+
raise ReplayLimitError.new(error_data[:message], request_id: request_id)
|
|
250
|
+
else
|
|
251
|
+
raise IdempotencyError.new(error_data[:message], request_id: request_id)
|
|
252
|
+
end
|
|
253
|
+
when 429
|
|
254
|
+
error_data = parse_error(response.body)
|
|
255
|
+
retry_after = response.headers["retry-after"]&.to_i
|
|
256
|
+
raise RateLimitError.new(error_data[:message], request_id: request_id, retry_after: retry_after)
|
|
257
|
+
else
|
|
258
|
+
error_data = parse_error(response.body)
|
|
259
|
+
raise Error.new(
|
|
260
|
+
error_data[:message] || "HTTP #{response.status}",
|
|
261
|
+
code: error_data[:code],
|
|
262
|
+
request_id: request_id,
|
|
263
|
+
status_code: response.status
|
|
264
|
+
)
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def parse_error(body)
|
|
269
|
+
return { message: "Unknown error", code: nil } if body.nil? || body.empty?
|
|
270
|
+
|
|
271
|
+
data = JSON.parse(body)
|
|
272
|
+
# API wraps errors in { error: { code: ..., message: ... }, meta: ... }
|
|
273
|
+
if data["error"].is_a?(Hash)
|
|
274
|
+
{
|
|
275
|
+
message: data["error"]["message"] || "Unknown error",
|
|
276
|
+
code: data["error"]["code"]
|
|
277
|
+
}
|
|
278
|
+
else
|
|
279
|
+
{
|
|
280
|
+
message: data["error"] || data["message"] || "Unknown error",
|
|
281
|
+
code: data["code"]
|
|
282
|
+
}
|
|
283
|
+
end
|
|
284
|
+
rescue JSON::ParserError
|
|
285
|
+
{ message: body, code: nil }
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def format_time(time)
|
|
289
|
+
case time
|
|
290
|
+
when Time
|
|
291
|
+
time.utc.iso8601
|
|
292
|
+
when String
|
|
293
|
+
time
|
|
294
|
+
else
|
|
295
|
+
time.to_s
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HookBridge
|
|
4
|
+
# Base error class for all HookBridge errors
|
|
5
|
+
class Error < StandardError
|
|
6
|
+
attr_reader :code, :request_id, :status_code
|
|
7
|
+
|
|
8
|
+
def initialize(message, code: nil, request_id: nil, status_code: nil)
|
|
9
|
+
@code = code
|
|
10
|
+
@request_id = request_id
|
|
11
|
+
@status_code = status_code
|
|
12
|
+
super(message)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Raised when authentication fails (401)
|
|
17
|
+
class AuthenticationError < Error
|
|
18
|
+
def initialize(message = "Invalid or missing API key", **kwargs)
|
|
19
|
+
super(message, code: "AUTHENTICATION_ERROR", status_code: 401, **kwargs)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Raised when a resource is not found (404)
|
|
24
|
+
class NotFoundError < Error
|
|
25
|
+
def initialize(message = "Resource not found", **kwargs)
|
|
26
|
+
super(message, code: "NOT_FOUND", status_code: 404, **kwargs)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Raised when request validation fails (400)
|
|
31
|
+
class ValidationError < Error
|
|
32
|
+
def initialize(message = "Validation failed", **kwargs)
|
|
33
|
+
super(message, code: "VALIDATION_ERROR", status_code: 400, **kwargs)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Raised when rate limit is exceeded (429)
|
|
38
|
+
class RateLimitError < Error
|
|
39
|
+
attr_reader :retry_after
|
|
40
|
+
|
|
41
|
+
def initialize(message = "Rate limit exceeded", retry_after: nil, **kwargs)
|
|
42
|
+
@retry_after = retry_after
|
|
43
|
+
super(message, code: "RATE_LIMIT_EXCEEDED", status_code: 429, **kwargs)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Raised when idempotency key conflict occurs (409)
|
|
48
|
+
class IdempotencyError < Error
|
|
49
|
+
def initialize(message = "Idempotency key conflict", **kwargs)
|
|
50
|
+
super(message, code: "IDEMPOTENCY_CONFLICT", status_code: 409, **kwargs)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Raised when replay limit is exceeded (429)
|
|
55
|
+
class ReplayLimitError < Error
|
|
56
|
+
def initialize(message = "Replay limit exceeded", **kwargs)
|
|
57
|
+
super(message, code: "REPLAY_LIMIT_EXCEEDED", status_code: 429, **kwargs)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Raised when a network error occurs
|
|
62
|
+
class NetworkError < Error
|
|
63
|
+
def initialize(message = "Network error", **kwargs)
|
|
64
|
+
super(message, code: "NETWORK_ERROR", **kwargs)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Raised when a request times out
|
|
69
|
+
class TimeoutError < Error
|
|
70
|
+
def initialize(message = "Request timed out", **kwargs)
|
|
71
|
+
super(message, code: "TIMEOUT", **kwargs)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module HookBridge
|
|
6
|
+
# Message status constants
|
|
7
|
+
module MessageStatus
|
|
8
|
+
QUEUED = "queued"
|
|
9
|
+
DELIVERING = "delivering"
|
|
10
|
+
SUCCEEDED = "succeeded"
|
|
11
|
+
PENDING_RETRY = "pending_retry"
|
|
12
|
+
FAILED_PERMANENT = "failed_permanent"
|
|
13
|
+
|
|
14
|
+
ALL = [QUEUED, DELIVERING, SUCCEEDED, PENDING_RETRY, FAILED_PERMANENT].freeze
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# API key mode constants
|
|
18
|
+
module APIKeyMode
|
|
19
|
+
LIVE = "live"
|
|
20
|
+
TEST = "test"
|
|
21
|
+
|
|
22
|
+
ALL = [LIVE, TEST].freeze
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Metrics time window constants
|
|
26
|
+
module MetricsWindow
|
|
27
|
+
ONE_HOUR = "1h"
|
|
28
|
+
TWENTY_FOUR_HOURS = "24h"
|
|
29
|
+
SEVEN_DAYS = "7d"
|
|
30
|
+
THIRTY_DAYS = "30d"
|
|
31
|
+
|
|
32
|
+
ALL = [ONE_HOUR, TWENTY_FOUR_HOURS, SEVEN_DAYS, THIRTY_DAYS].freeze
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Full message details
|
|
36
|
+
class Message
|
|
37
|
+
attr_reader :id, :status, :project_id, :endpoint_id, :attempt_count, :replay_count,
|
|
38
|
+
:payload_sha256, :content_type, :size_bytes, :idempotency_key,
|
|
39
|
+
:next_attempt_at, :last_error, :response_status, :response_latency_ms,
|
|
40
|
+
:created_at, :updated_at
|
|
41
|
+
|
|
42
|
+
def initialize(data)
|
|
43
|
+
@id = data["id"]
|
|
44
|
+
@status = data["status"]
|
|
45
|
+
@project_id = data["project_id"]
|
|
46
|
+
@endpoint_id = data["endpoint_id"]
|
|
47
|
+
@attempt_count = data["attempt_count"]
|
|
48
|
+
@replay_count = data["replay_count"]
|
|
49
|
+
@payload_sha256 = data["payload_sha256"]
|
|
50
|
+
@content_type = data["content_type"]
|
|
51
|
+
@size_bytes = data["size_bytes"]
|
|
52
|
+
@idempotency_key = data["idempotency_key"]
|
|
53
|
+
@next_attempt_at = parse_time(data["next_attempt_at"])
|
|
54
|
+
@last_error = data["last_error"]
|
|
55
|
+
@response_status = data["response_status"]
|
|
56
|
+
@response_latency_ms = data["response_latency_ms"]
|
|
57
|
+
@created_at = parse_time(data["created_at"])
|
|
58
|
+
@updated_at = parse_time(data["updated_at"])
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def to_h
|
|
62
|
+
{
|
|
63
|
+
id: @id,
|
|
64
|
+
status: @status,
|
|
65
|
+
project_id: @project_id,
|
|
66
|
+
endpoint_id: @endpoint_id,
|
|
67
|
+
attempt_count: @attempt_count,
|
|
68
|
+
replay_count: @replay_count,
|
|
69
|
+
payload_sha256: @payload_sha256,
|
|
70
|
+
content_type: @content_type,
|
|
71
|
+
size_bytes: @size_bytes,
|
|
72
|
+
idempotency_key: @idempotency_key,
|
|
73
|
+
next_attempt_at: @next_attempt_at,
|
|
74
|
+
last_error: @last_error,
|
|
75
|
+
response_status: @response_status,
|
|
76
|
+
response_latency_ms: @response_latency_ms,
|
|
77
|
+
created_at: @created_at,
|
|
78
|
+
updated_at: @updated_at
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def parse_time(value)
|
|
85
|
+
return nil if value.nil?
|
|
86
|
+
|
|
87
|
+
Time.parse(value)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Lighter message summary for logs
|
|
92
|
+
class MessageSummary
|
|
93
|
+
attr_reader :message_id, :endpoint, :status, :attempt_count, :created_at,
|
|
94
|
+
:delivered_at, :response_status, :response_latency_ms, :last_error
|
|
95
|
+
|
|
96
|
+
def initialize(data)
|
|
97
|
+
@message_id = data["message_id"]
|
|
98
|
+
@endpoint = data["endpoint"]
|
|
99
|
+
@status = data["status"]
|
|
100
|
+
@attempt_count = data["attempt_count"]
|
|
101
|
+
@created_at = parse_time(data["created_at"])
|
|
102
|
+
@delivered_at = parse_time(data["delivered_at"])
|
|
103
|
+
@response_status = data["response_status"]
|
|
104
|
+
@response_latency_ms = data["response_latency_ms"]
|
|
105
|
+
@last_error = data["last_error"]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def to_h
|
|
109
|
+
{
|
|
110
|
+
message_id: @message_id,
|
|
111
|
+
endpoint: @endpoint,
|
|
112
|
+
status: @status,
|
|
113
|
+
attempt_count: @attempt_count,
|
|
114
|
+
created_at: @created_at,
|
|
115
|
+
delivered_at: @delivered_at,
|
|
116
|
+
response_status: @response_status,
|
|
117
|
+
response_latency_ms: @response_latency_ms,
|
|
118
|
+
last_error: @last_error
|
|
119
|
+
}
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def parse_time(value)
|
|
125
|
+
return nil if value.nil?
|
|
126
|
+
|
|
127
|
+
Time.parse(value)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Send webhook response
|
|
132
|
+
class SendResponse
|
|
133
|
+
attr_reader :message_id, :status
|
|
134
|
+
|
|
135
|
+
def initialize(data)
|
|
136
|
+
@message_id = data["message_id"]
|
|
137
|
+
@status = data["status"]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def to_h
|
|
141
|
+
{ message_id: @message_id, status: @status }
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Replay response
|
|
146
|
+
class ReplayResponse
|
|
147
|
+
attr_reader :message_id, :status, :attempt_count
|
|
148
|
+
|
|
149
|
+
def initialize(data)
|
|
150
|
+
@message_id = data["message_id"]
|
|
151
|
+
@status = data["status"]
|
|
152
|
+
@attempt_count = data["attempt_count"]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def to_h
|
|
156
|
+
{ message_id: @message_id, status: @status, attempt_count: @attempt_count }
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Paginated list of log entries
|
|
161
|
+
class LogsResponse
|
|
162
|
+
attr_reader :messages, :has_more, :next_cursor
|
|
163
|
+
|
|
164
|
+
def initialize(data)
|
|
165
|
+
# data is an array directly, pagination info is at top level
|
|
166
|
+
messages_data = data["data"] || data
|
|
167
|
+
messages_data = [] unless messages_data.is_a?(Array)
|
|
168
|
+
@messages = messages_data.map { |m| MessageSummary.new(m) }
|
|
169
|
+
@has_more = data["has_more"] || false
|
|
170
|
+
@next_cursor = data["next_cursor"]
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def to_h
|
|
174
|
+
{
|
|
175
|
+
messages: @messages.map(&:to_h),
|
|
176
|
+
has_more: @has_more,
|
|
177
|
+
next_cursor: @next_cursor
|
|
178
|
+
}
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Delivery metrics
|
|
183
|
+
class Metrics
|
|
184
|
+
attr_reader :window, :total_messages, :succeeded, :failed, :retries,
|
|
185
|
+
:success_rate, :avg_latency_ms
|
|
186
|
+
|
|
187
|
+
def initialize(data)
|
|
188
|
+
@window = data["window"]
|
|
189
|
+
@total_messages = data["total_messages"]
|
|
190
|
+
@succeeded = data["succeeded"]
|
|
191
|
+
@failed = data["failed"]
|
|
192
|
+
@retries = data["retries"]
|
|
193
|
+
@success_rate = data["success_rate"]
|
|
194
|
+
@avg_latency_ms = data["avg_latency_ms"]
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def to_h
|
|
198
|
+
{
|
|
199
|
+
window: @window,
|
|
200
|
+
total_messages: @total_messages,
|
|
201
|
+
succeeded: @succeeded,
|
|
202
|
+
failed: @failed,
|
|
203
|
+
retries: @retries,
|
|
204
|
+
success_rate: @success_rate,
|
|
205
|
+
avg_latency_ms: @avg_latency_ms
|
|
206
|
+
}
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Dead Letter Queue message
|
|
211
|
+
class DLQMessage
|
|
212
|
+
attr_reader :message_id, :endpoint, :failed_at, :reason, :attempt_count
|
|
213
|
+
|
|
214
|
+
def initialize(data)
|
|
215
|
+
@message_id = data["message_id"]
|
|
216
|
+
@endpoint = data["endpoint"]
|
|
217
|
+
@failed_at = parse_time(data["failed_at"])
|
|
218
|
+
@reason = data["reason"]
|
|
219
|
+
@attempt_count = data["attempt_count"]
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def to_h
|
|
223
|
+
{
|
|
224
|
+
message_id: @message_id,
|
|
225
|
+
endpoint: @endpoint,
|
|
226
|
+
failed_at: @failed_at,
|
|
227
|
+
reason: @reason,
|
|
228
|
+
attempt_count: @attempt_count
|
|
229
|
+
}
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
private
|
|
233
|
+
|
|
234
|
+
def parse_time(value)
|
|
235
|
+
return nil if value.nil?
|
|
236
|
+
|
|
237
|
+
Time.parse(value)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Paginated list of DLQ messages
|
|
242
|
+
class DLQResponse
|
|
243
|
+
attr_reader :messages, :has_more, :next_cursor
|
|
244
|
+
|
|
245
|
+
def initialize(data)
|
|
246
|
+
# DLQ response: data can be { messages: [...] } or have messages at top level
|
|
247
|
+
messages_data = if data.is_a?(Hash) && data["data"].is_a?(Hash)
|
|
248
|
+
data.dig("data", "messages") || []
|
|
249
|
+
elsif data.is_a?(Hash) && data["messages"]
|
|
250
|
+
data["messages"]
|
|
251
|
+
elsif data.is_a?(Array)
|
|
252
|
+
data
|
|
253
|
+
else
|
|
254
|
+
[]
|
|
255
|
+
end
|
|
256
|
+
@messages = messages_data.map { |m| MessageSummary.new(m) }
|
|
257
|
+
@has_more = data["has_more"] || false
|
|
258
|
+
@next_cursor = data["next_cursor"]
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def to_h
|
|
262
|
+
{
|
|
263
|
+
messages: @messages.map(&:to_h),
|
|
264
|
+
has_more: @has_more,
|
|
265
|
+
next_cursor: @next_cursor
|
|
266
|
+
}
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# API key information
|
|
271
|
+
class APIKey
|
|
272
|
+
attr_reader :key_id, :label, :prefix, :created_at, :last_used_at
|
|
273
|
+
|
|
274
|
+
# Aliases for backwards compatibility
|
|
275
|
+
alias id key_id
|
|
276
|
+
alias name label
|
|
277
|
+
|
|
278
|
+
def initialize(data)
|
|
279
|
+
@key_id = data["key_id"]
|
|
280
|
+
@label = data["label"]
|
|
281
|
+
@prefix = data["prefix"]
|
|
282
|
+
@created_at = parse_time(data["created_at"])
|
|
283
|
+
@last_used_at = parse_time(data["last_used_at"])
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def to_h
|
|
287
|
+
{
|
|
288
|
+
key_id: @key_id,
|
|
289
|
+
label: @label,
|
|
290
|
+
prefix: @prefix,
|
|
291
|
+
created_at: @created_at,
|
|
292
|
+
last_used_at: @last_used_at
|
|
293
|
+
}
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
private
|
|
297
|
+
|
|
298
|
+
def parse_time(value)
|
|
299
|
+
return nil if value.nil?
|
|
300
|
+
|
|
301
|
+
Time.parse(value)
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# API key creation response (includes full key)
|
|
306
|
+
class APIKeyCreated
|
|
307
|
+
attr_reader :key_id, :label, :key, :prefix, :created_at
|
|
308
|
+
|
|
309
|
+
# Aliases for backwards compatibility
|
|
310
|
+
alias id key_id
|
|
311
|
+
alias name label
|
|
312
|
+
|
|
313
|
+
def initialize(data)
|
|
314
|
+
@key_id = data["key_id"]
|
|
315
|
+
@label = data["label"]
|
|
316
|
+
@key = data["key"]
|
|
317
|
+
@prefix = data["prefix"]
|
|
318
|
+
@created_at = parse_time(data["created_at"])
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def to_h
|
|
322
|
+
{
|
|
323
|
+
key_id: @key_id,
|
|
324
|
+
label: @label,
|
|
325
|
+
key: @key,
|
|
326
|
+
prefix: @prefix,
|
|
327
|
+
created_at: @created_at
|
|
328
|
+
}
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
private
|
|
332
|
+
|
|
333
|
+
def parse_time(value)
|
|
334
|
+
return nil if value.nil?
|
|
335
|
+
|
|
336
|
+
Time.parse(value)
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# List of API keys
|
|
341
|
+
class APIKeysResponse
|
|
342
|
+
attr_reader :keys
|
|
343
|
+
|
|
344
|
+
def initialize(data)
|
|
345
|
+
# data is an array directly
|
|
346
|
+
keys_data = data.is_a?(Array) ? data : (data["data"] || data["keys"] || [])
|
|
347
|
+
keys_data = [] unless keys_data.is_a?(Array)
|
|
348
|
+
@keys = keys_data.map { |k| APIKey.new(k) }
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def to_h
|
|
352
|
+
{ keys: @keys.map(&:to_h) }
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
end
|
data/lib/hookbridge.rb
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "hookbridge/version"
|
|
4
|
+
require_relative "hookbridge/errors"
|
|
5
|
+
require_relative "hookbridge/types"
|
|
6
|
+
require_relative "hookbridge/client"
|
|
7
|
+
|
|
8
|
+
module HookBridge
|
|
9
|
+
class << self
|
|
10
|
+
# Create a new HookBridge client
|
|
11
|
+
#
|
|
12
|
+
# @param api_key [String] Your HookBridge API key
|
|
13
|
+
# @param options [Hash] Additional options (base_url, timeout, retries)
|
|
14
|
+
# @return [Client] A new HookBridge client instance
|
|
15
|
+
def new(api_key:, **options)
|
|
16
|
+
Client.new(api_key: api_key, **options)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: hookbridge
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- HookBridge
|
|
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: faraday
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '2.0'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '3.0'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '2.0'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '3.0'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: faraday-retry
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - "~>"
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '2.0'
|
|
39
|
+
type: :runtime
|
|
40
|
+
prerelease: false
|
|
41
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
42
|
+
requirements:
|
|
43
|
+
- - "~>"
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '2.0'
|
|
46
|
+
- !ruby/object:Gem::Dependency
|
|
47
|
+
name: bundler
|
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
|
49
|
+
requirements:
|
|
50
|
+
- - "~>"
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '2.0'
|
|
53
|
+
type: :development
|
|
54
|
+
prerelease: false
|
|
55
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - "~>"
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: '2.0'
|
|
60
|
+
- !ruby/object:Gem::Dependency
|
|
61
|
+
name: rake
|
|
62
|
+
requirement: !ruby/object:Gem::Requirement
|
|
63
|
+
requirements:
|
|
64
|
+
- - "~>"
|
|
65
|
+
- !ruby/object:Gem::Version
|
|
66
|
+
version: '13.0'
|
|
67
|
+
type: :development
|
|
68
|
+
prerelease: false
|
|
69
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
70
|
+
requirements:
|
|
71
|
+
- - "~>"
|
|
72
|
+
- !ruby/object:Gem::Version
|
|
73
|
+
version: '13.0'
|
|
74
|
+
- !ruby/object:Gem::Dependency
|
|
75
|
+
name: rspec
|
|
76
|
+
requirement: !ruby/object:Gem::Requirement
|
|
77
|
+
requirements:
|
|
78
|
+
- - "~>"
|
|
79
|
+
- !ruby/object:Gem::Version
|
|
80
|
+
version: '3.12'
|
|
81
|
+
type: :development
|
|
82
|
+
prerelease: false
|
|
83
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
84
|
+
requirements:
|
|
85
|
+
- - "~>"
|
|
86
|
+
- !ruby/object:Gem::Version
|
|
87
|
+
version: '3.12'
|
|
88
|
+
- !ruby/object:Gem::Dependency
|
|
89
|
+
name: webmock
|
|
90
|
+
requirement: !ruby/object:Gem::Requirement
|
|
91
|
+
requirements:
|
|
92
|
+
- - "~>"
|
|
93
|
+
- !ruby/object:Gem::Version
|
|
94
|
+
version: '3.18'
|
|
95
|
+
type: :development
|
|
96
|
+
prerelease: false
|
|
97
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
98
|
+
requirements:
|
|
99
|
+
- - "~>"
|
|
100
|
+
- !ruby/object:Gem::Version
|
|
101
|
+
version: '3.18'
|
|
102
|
+
- !ruby/object:Gem::Dependency
|
|
103
|
+
name: rubocop
|
|
104
|
+
requirement: !ruby/object:Gem::Requirement
|
|
105
|
+
requirements:
|
|
106
|
+
- - "~>"
|
|
107
|
+
- !ruby/object:Gem::Version
|
|
108
|
+
version: '1.50'
|
|
109
|
+
type: :development
|
|
110
|
+
prerelease: false
|
|
111
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
112
|
+
requirements:
|
|
113
|
+
- - "~>"
|
|
114
|
+
- !ruby/object:Gem::Version
|
|
115
|
+
version: '1.50'
|
|
116
|
+
- !ruby/object:Gem::Dependency
|
|
117
|
+
name: rubocop-rspec
|
|
118
|
+
requirement: !ruby/object:Gem::Requirement
|
|
119
|
+
requirements:
|
|
120
|
+
- - "~>"
|
|
121
|
+
- !ruby/object:Gem::Version
|
|
122
|
+
version: '2.20'
|
|
123
|
+
type: :development
|
|
124
|
+
prerelease: false
|
|
125
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
126
|
+
requirements:
|
|
127
|
+
- - "~>"
|
|
128
|
+
- !ruby/object:Gem::Version
|
|
129
|
+
version: '2.20'
|
|
130
|
+
- !ruby/object:Gem::Dependency
|
|
131
|
+
name: yard
|
|
132
|
+
requirement: !ruby/object:Gem::Requirement
|
|
133
|
+
requirements:
|
|
134
|
+
- - "~>"
|
|
135
|
+
- !ruby/object:Gem::Version
|
|
136
|
+
version: '0.9'
|
|
137
|
+
type: :development
|
|
138
|
+
prerelease: false
|
|
139
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
140
|
+
requirements:
|
|
141
|
+
- - "~>"
|
|
142
|
+
- !ruby/object:Gem::Version
|
|
143
|
+
version: '0.9'
|
|
144
|
+
description: Official Ruby client library for the HookBridge API. Send webhooks with
|
|
145
|
+
guaranteed delivery, automatic retries, and comprehensive delivery tracking.
|
|
146
|
+
email:
|
|
147
|
+
- support@hookbridge.io
|
|
148
|
+
executables: []
|
|
149
|
+
extensions: []
|
|
150
|
+
extra_rdoc_files: []
|
|
151
|
+
files:
|
|
152
|
+
- CHANGELOG.md
|
|
153
|
+
- LICENSE
|
|
154
|
+
- README.md
|
|
155
|
+
- lib/hookbridge.rb
|
|
156
|
+
- lib/hookbridge/client.rb
|
|
157
|
+
- lib/hookbridge/errors.rb
|
|
158
|
+
- lib/hookbridge/types.rb
|
|
159
|
+
- lib/hookbridge/version.rb
|
|
160
|
+
homepage: https://github.com/hookbridge/hookbridge-ruby
|
|
161
|
+
licenses:
|
|
162
|
+
- MIT
|
|
163
|
+
metadata:
|
|
164
|
+
homepage_uri: https://github.com/hookbridge/hookbridge-ruby
|
|
165
|
+
source_code_uri: https://github.com/hookbridge/hookbridge-ruby
|
|
166
|
+
changelog_uri: https://github.com/hookbridge/hookbridge-ruby/blob/main/CHANGELOG.md
|
|
167
|
+
documentation_uri: https://docs.hookbridge.io
|
|
168
|
+
rubygems_mfa_required: 'true'
|
|
169
|
+
rdoc_options: []
|
|
170
|
+
require_paths:
|
|
171
|
+
- lib
|
|
172
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
173
|
+
requirements:
|
|
174
|
+
- - ">="
|
|
175
|
+
- !ruby/object:Gem::Version
|
|
176
|
+
version: 3.0.0
|
|
177
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
178
|
+
requirements:
|
|
179
|
+
- - ">="
|
|
180
|
+
- !ruby/object:Gem::Version
|
|
181
|
+
version: '0'
|
|
182
|
+
requirements: []
|
|
183
|
+
rubygems_version: 3.7.2
|
|
184
|
+
specification_version: 4
|
|
185
|
+
summary: Ruby SDK for HookBridge - Guaranteed webhook delivery
|
|
186
|
+
test_files: []
|