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 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HookBridge
4
+ VERSION = "1.0.0"
5
+ 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: []