hookbridge 1.0.0 → 1.3.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.
@@ -5,93 +5,64 @@ require "faraday/retry"
5
5
  require "json"
6
6
 
7
7
  module HookBridge
8
- # Main client for interacting with the HookBridge API
9
8
  class Client
10
9
  DEFAULT_BASE_URL = "https://api.hookbridge.io"
10
+ DEFAULT_SEND_URL = "https://send.hookbridge.io"
11
11
  DEFAULT_TIMEOUT = 30
12
12
  DEFAULT_RETRIES = 3
13
13
 
14
- attr_reader :api_key, :base_url, :timeout, :retries
14
+ attr_reader :api_key, :base_url, :send_url, :timeout, :retries
15
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)
16
+ def initialize(api_key:, base_url: nil, send_url: nil, timeout: DEFAULT_TIMEOUT, retries: DEFAULT_RETRIES)
23
17
  raise ValidationError, "API key is required" if api_key.nil? || api_key.empty?
24
18
 
25
19
  @api_key = api_key
26
20
  @base_url = (base_url || ENV.fetch("HOOKBRIDGE_BASE_URL", DEFAULT_BASE_URL)).chomp("/")
21
+ @send_url = (send_url || ENV.fetch("HOOKBRIDGE_SEND_URL", DEFAULT_SEND_URL)).chomp("/")
27
22
  @timeout = timeout
28
23
  @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
24
+ @connection = build_connection(@base_url)
25
+ @send_connection = build_connection(@send_url)
26
+ end
46
27
 
47
- data = request(:post, "/v1/webhooks/send", body)
48
- SendResponse.new(data)
28
+ def send(endpoint_id:, payload:, headers: nil, idempotency_key: nil)
29
+ body = { endpoint_id: endpoint_id, payload: payload }
30
+ body[:headers] = headers if headers
31
+ body[:idempotency_key] = idempotency_key if idempotency_key
32
+ SendResponse.new(extract_data(request(:post, "/v1/webhooks/send", body, use_send_connection: true)))
49
33
  end
50
34
 
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
35
  def get_message(message_id)
56
- data = request(:get, "/v1/messages/#{message_id}")
57
- Message.new(data)
36
+ Message.new(extract_data(request(:get, "/v1/messages/#{message_id}")))
58
37
  end
59
38
 
60
- # Manually replay a failed message
61
- #
62
- # @param message_id [String] The message ID to replay
63
- # @return [ReplayResponse] The replay response
64
39
  def replay(message_id)
65
- data = request(:post, "/v1/messages/#{message_id}/replay")
66
- ReplayResponse.new(data)
40
+ ReplayResponse.new(extract_data(request(:post, "/v1/messages/#{message_id}/replay")))
67
41
  end
68
42
 
69
- # Cancel a pending retry
70
- #
71
- # @param message_id [String] The message ID
72
- # @return [Message] The updated message
73
43
  def cancel_retry(message_id)
74
- data = request(:post, "/v1/messages/#{message_id}/cancel")
75
- Message.new(data)
44
+ request(:post, "/v1/messages/#{message_id}/cancel")
45
+ nil
76
46
  end
77
47
 
78
- # Trigger immediate retry for a pending message
79
- #
80
- # @param message_id [String] The message ID
81
- # @return [Message] The updated message
82
48
  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
49
+ request(:post, "/v1/messages/#{message_id}/retry-now")
50
+ nil
51
+ end
52
+
53
+ def replay_all_messages(status:, endpoint_id: nil, limit: nil)
54
+ params = { status: status }
55
+ params[:endpoint_id] = endpoint_id if endpoint_id
56
+ params[:limit] = limit if limit
57
+ ReplayAllMessagesResponse.new(extract_data(request(:post, "/v1/messages/replay-all", nil, params)))
58
+ end
59
+
60
+ def replay_batch_messages(message_ids)
61
+ ReplayBatchMessagesResponse.new(
62
+ extract_data(request(:post, "/v1/messages/replay-batch", { message_ids: message_ids }))
63
+ )
64
+ end
65
+
95
66
  def get_logs(status: nil, start_time: nil, end_time: nil, limit: nil, cursor: nil)
96
67
  params = {}
97
68
  params[:status] = status if status
@@ -99,200 +70,414 @@ module HookBridge
99
70
  params[:end_time] = format_time(end_time) if end_time
100
71
  params[:limit] = limit if limit
101
72
  params[:cursor] = cursor if cursor
102
-
103
- data = request(:get, "/v1/logs", nil, params)
104
- LogsResponse.new(data)
73
+ response = request(:get, "/v1/logs", nil, params)
74
+ LogsResponse.new(
75
+ "data" => extract_data(response),
76
+ "has_more" => extract_meta(response)["has_more"] || response["has_more"],
77
+ "next_cursor" => extract_meta(response)["next_cursor"] || response["next_cursor"]
78
+ )
105
79
  end
106
80
 
107
- # Retrieve aggregated delivery metrics
108
- #
109
- # @param window [String] Time window: "1h", "24h", "7d", or "30d"
110
- # @return [Metrics] The delivery metrics
111
81
  def get_metrics(window: MetricsWindow::TWENTY_FOUR_HOURS)
112
- data = request(:get, "/v1/metrics", nil, { window: window })
113
- Metrics.new(data)
82
+ Metrics.new(extract_data(request(:get, "/v1/metrics", nil, { window: window })))
83
+ end
84
+
85
+ def get_timeseries_metrics(window: MetricsWindow::TWENTY_FOUR_HOURS, endpoint_id: nil)
86
+ params = { window: window }
87
+ params[:endpoint_id] = endpoint_id if endpoint_id
88
+ TimeSeriesMetrics.new(extract_data(request(:get, "/v1/metrics/timeseries", nil, params)))
89
+ end
90
+
91
+ def get_subscription
92
+ Subscription.new(extract_data(request(:get, "/v1/billing/subscription")))
114
93
  end
115
94
 
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
95
  def get_dlq_messages(limit: nil, cursor: nil)
122
96
  params = {}
123
97
  params[:limit] = limit if limit
124
98
  params[:cursor] = cursor if cursor
125
-
126
- data = request(:get, "/v1/dlq/messages", nil, params)
127
- DLQResponse.new(data)
99
+ response = request(:get, "/v1/dlq/messages", nil, params)
100
+ DLQResponse.new(
101
+ "data" => extract_data(response),
102
+ "has_more" => extract_data(response)["has_more"] || response["has_more"],
103
+ "next_cursor" => extract_data(response)["next_cursor"] || response["next_cursor"]
104
+ )
128
105
  end
129
106
 
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
107
  def replay_from_dlq(message_id)
135
- data = request(:post, "/v1/dlq/replay/#{message_id}")
136
- ReplayResponse.new(data)
108
+ ReplayResponse.new(extract_data(request(:post, "/v1/dlq/replay/#{message_id}")))
137
109
  end
138
110
 
139
- # List all API keys for the project
140
- #
141
- # @return [APIKeysResponse] List of API keys
142
111
  def list_api_keys
143
- data = request(:get, "/v1/api-keys")
144
- APIKeysResponse.new(data)
112
+ APIKeysResponse.new("data" => extract_data(request(:get, "/v1/api-keys")))
145
113
  end
146
114
 
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
115
  def create_api_key(label: nil, mode: APIKeyMode::LIVE)
153
116
  body = { mode: mode }
154
117
  body[:label] = label if label
155
- data = request(:post, "/v1/api-keys", body)
156
- APIKeyCreated.new(data)
118
+ APIKeyCreated.new(extract_data(request(:post, "/v1/api-keys", body)))
157
119
  end
158
120
 
159
- # Delete an API key
160
- #
161
- # @param key_id [String] The API key ID to delete
162
- # @return [Boolean] true if successful
163
121
  def delete_api_key(key_id)
164
122
  request(:delete, "/v1/api-keys/#{key_id}")
165
123
  true
166
124
  end
167
125
 
126
+ def list_projects
127
+ extract_data(request(:get, "/v1/projects")).map { |project| Project.new(project) }
128
+ end
129
+
130
+ def create_project(name:, rate_limit_default: nil)
131
+ body = { name: name }
132
+ body[:rate_limit_default] = rate_limit_default if rate_limit_default
133
+ Project.new(extract_data(request(:post, "/v1/projects", body)))
134
+ end
135
+
136
+ def get_project(project_id)
137
+ Project.new(extract_data(request(:get, "/v1/projects/#{project_id}")))
138
+ end
139
+
140
+ def update_project(project_id, name: nil, rate_limit_default: nil)
141
+ body = {}
142
+ body[:name] = name if name
143
+ body[:rate_limit_default] = rate_limit_default if rate_limit_default
144
+ Project.new(extract_data(request(:put, "/v1/projects/#{project_id}", body)))
145
+ end
146
+
147
+ def delete_project(project_id)
148
+ request(:delete, "/v1/projects/#{project_id}")
149
+ true
150
+ end
151
+
152
+ def create_endpoint(url:, description: nil, hmac_enabled: nil, rate_limit_rps: 0, burst: 0, headers: nil)
153
+ body = { url: url }
154
+ body[:description] = description if description
155
+ body[:hmac_enabled] = hmac_enabled unless hmac_enabled.nil?
156
+ body[:rate_limit_rps] = rate_limit_rps if rate_limit_rps.positive?
157
+ body[:burst] = burst if burst.positive?
158
+ body[:headers] = headers if headers
159
+ CreateEndpointResponse.new(extract_data(request(:post, "/v1/endpoints", body)))
160
+ end
161
+
162
+ def get_endpoint(endpoint_id)
163
+ Endpoint.new(extract_data(request(:get, "/v1/endpoints/#{endpoint_id}")))
164
+ end
165
+
166
+ def list_endpoints(limit: nil, cursor: nil)
167
+ params = {}
168
+ params[:limit] = limit if limit
169
+ params[:cursor] = cursor if cursor
170
+ response = request(:get, "/v1/endpoints", nil, params)
171
+ ListEndpointsResponse.new(
172
+ "data" => extract_data(response),
173
+ "has_more" => extract_meta(response)["has_more"] || response["has_more"],
174
+ "next_cursor" => extract_meta(response)["next_cursor"] || response["next_cursor"]
175
+ )
176
+ end
177
+
178
+ def update_endpoint(endpoint_id, url: nil, description: nil, hmac_enabled: nil, rate_limit_rps: nil, burst: nil, headers: nil)
179
+ body = {}
180
+ body[:url] = url if url
181
+ body[:description] = description if description
182
+ body[:hmac_enabled] = hmac_enabled unless hmac_enabled.nil?
183
+ body[:rate_limit_rps] = rate_limit_rps if rate_limit_rps
184
+ body[:burst] = burst if burst
185
+ body[:headers] = headers if headers
186
+ request(:patch, "/v1/endpoints/#{endpoint_id}", body)
187
+ nil
188
+ end
189
+
190
+ def delete_endpoint(endpoint_id)
191
+ request(:delete, "/v1/endpoints/#{endpoint_id}")
192
+ true
193
+ end
194
+
195
+ def create_endpoint_signing_key(endpoint_id)
196
+ RotateSecretResponse.new(extract_data(request(:post, "/v1/endpoints/#{endpoint_id}/signing-keys")))
197
+ end
198
+
199
+ def list_endpoint_signing_keys(endpoint_id)
200
+ extract_data(request(:get, "/v1/endpoints/#{endpoint_id}/signing-keys")).map do |entry|
201
+ SigningKey.new(entry)
202
+ end
203
+ end
204
+
205
+ def delete_endpoint_signing_key(endpoint_id, key_id)
206
+ request(:delete, "/v1/endpoints/#{endpoint_id}/signing-keys/#{key_id}")
207
+ true
208
+ end
209
+
210
+ def rotate_endpoint_secret(endpoint_id)
211
+ create_endpoint_signing_key(endpoint_id)
212
+ end
213
+
214
+ def create_checkout(plan:, interval:)
215
+ CheckoutSession.new(extract_data(request(:post, "/v1/billing/checkout", { plan: plan, interval: interval })))
216
+ end
217
+
218
+ def create_portal(return_url: nil)
219
+ body = {}
220
+ body[:return_url] = return_url if return_url
221
+ PortalSession.new(extract_data(request(:post, "/v1/billing/portal", body)))
222
+ end
223
+
224
+ def get_usage_history(limit: 12, offset: 0)
225
+ response = request(:get, "/v1/billing/usage-history", nil, { limit: limit, offset: offset })
226
+ UsageHistoryResponse.new(extract_data(response), extract_meta(response))
227
+ end
228
+
229
+ def get_invoices(limit: 12, starting_after: nil)
230
+ params = { limit: limit }
231
+ params[:starting_after] = starting_after if starting_after
232
+ response = request(:get, "/v1/billing/invoices", nil, params)
233
+ InvoicesResponse.new(extract_data(response), extract_meta(response))
234
+ end
235
+
236
+ def create_inbound_endpoint(url:, name: nil, description: nil, verify_static_token: nil, token_header_name: nil, token_query_param: nil, token_value: nil, verify_hmac: nil, hmac_header_name: nil, hmac_secret: nil, timestamp_header_name: nil, timestamp_ttl_seconds: nil, verify_ip_allowlist: nil, allowed_cidrs: nil, idempotency_header_names: nil, ingest_response_code: nil, signing_enabled: nil)
237
+ body = { url: url }
238
+ {
239
+ name: name,
240
+ description: description,
241
+ verify_static_token: verify_static_token,
242
+ token_header_name: token_header_name,
243
+ token_query_param: token_query_param,
244
+ token_value: token_value,
245
+ verify_hmac: verify_hmac,
246
+ hmac_header_name: hmac_header_name,
247
+ hmac_secret: hmac_secret,
248
+ timestamp_header_name: timestamp_header_name,
249
+ timestamp_ttl_seconds: timestamp_ttl_seconds,
250
+ verify_ip_allowlist: verify_ip_allowlist,
251
+ allowed_cidrs: allowed_cidrs,
252
+ idempotency_header_names: idempotency_header_names,
253
+ ingest_response_code: ingest_response_code,
254
+ signing_enabled: signing_enabled
255
+ }.each do |key, value|
256
+ body[key] = value unless value.nil?
257
+ end
258
+ CreateInboundEndpointResponse.new(extract_data(request(:post, "/v1/inbound-endpoints", body)))
259
+ end
260
+
261
+ def list_inbound_endpoints(limit: nil, cursor: nil)
262
+ params = {}
263
+ params[:limit] = limit if limit
264
+ params[:cursor] = cursor if cursor
265
+ response = request(:get, "/v1/inbound-endpoints", nil, params)
266
+ ListInboundEndpointsResponse.new(
267
+ "data" => extract_data(response),
268
+ "has_more" => extract_meta(response)["has_more"] || response["has_more"],
269
+ "next_cursor" => extract_meta(response)["next_cursor"] || response["next_cursor"]
270
+ )
271
+ end
272
+
273
+ def get_inbound_endpoint(endpoint_id)
274
+ InboundEndpoint.new(extract_data(request(:get, "/v1/inbound-endpoints/#{endpoint_id}")))
275
+ end
276
+
277
+ def update_inbound_endpoint(endpoint_id, **attributes)
278
+ UpdateResult.new(extract_data(request(:patch, "/v1/inbound-endpoints/#{endpoint_id}", compact_hash(attributes))))
279
+ end
280
+
281
+ def delete_inbound_endpoint(endpoint_id)
282
+ DeleteResult.new(extract_data(request(:delete, "/v1/inbound-endpoints/#{endpoint_id}")))
283
+ end
284
+
285
+ def pause_inbound_endpoint(endpoint_id)
286
+ PauseState.new(extract_data(request(:post, "/v1/inbound-endpoints/#{endpoint_id}/pause")))
287
+ end
288
+
289
+ def resume_inbound_endpoint(endpoint_id)
290
+ PauseState.new(extract_data(request(:post, "/v1/inbound-endpoints/#{endpoint_id}/resume")))
291
+ end
292
+
293
+ def replay_inbound_message(message_id)
294
+ ReplayResponse.new(extract_data(request(:post, "/v1/inbound-messages/#{message_id}/replay")))
295
+ end
296
+
297
+ def replay_all_inbound_messages(status:, inbound_endpoint_id: nil, limit: nil)
298
+ params = { status: status }
299
+ params[:inbound_endpoint_id] = inbound_endpoint_id if inbound_endpoint_id
300
+ params[:limit] = limit if limit
301
+ ReplayAllMessagesResponse.new(extract_data(request(:post, "/v1/inbound-messages/replay-all", nil, params)))
302
+ end
303
+
304
+ def replay_batch_inbound_messages(message_ids)
305
+ ReplayBatchMessagesResponse.new(
306
+ extract_data(request(:post, "/v1/inbound-messages/replay-batch", { message_ids: message_ids }))
307
+ )
308
+ end
309
+
310
+ def get_inbound_logs(status: nil, inbound_endpoint_id: nil, start_time: nil, end_time: nil, limit: nil, cursor: nil)
311
+ params = {}
312
+ params[:status] = status if status
313
+ params[:inbound_endpoint_id] = inbound_endpoint_id if inbound_endpoint_id
314
+ params[:start_time] = format_time(start_time) if start_time
315
+ params[:end_time] = format_time(end_time) if end_time
316
+ params[:limit] = limit if limit
317
+ params[:cursor] = cursor if cursor
318
+ response = request(:get, "/v1/inbound-logs", nil, params)
319
+ InboundLogsResponse.new(
320
+ "data" => extract_data(response),
321
+ "has_more" => extract_meta(response)["has_more"] || response["has_more"],
322
+ "next_cursor" => extract_meta(response)["next_cursor"] || response["next_cursor"]
323
+ )
324
+ end
325
+
326
+ def get_inbound_metrics(window: MetricsWindow::TWENTY_FOUR_HOURS, inbound_endpoint_id: nil)
327
+ params = { window: window }
328
+ params[:inbound_endpoint_id] = inbound_endpoint_id if inbound_endpoint_id
329
+ InboundMetrics.new(extract_data(request(:get, "/v1/inbound-metrics", nil, params)))
330
+ end
331
+
332
+ def get_inbound_timeseries_metrics(window: MetricsWindow::TWENTY_FOUR_HOURS, inbound_endpoint_id: nil)
333
+ params = { window: window }
334
+ params[:inbound_endpoint_id] = inbound_endpoint_id if inbound_endpoint_id
335
+ TimeSeriesMetrics.new(extract_data(request(:get, "/v1/inbound-metrics/timeseries", nil, params)))
336
+ end
337
+
338
+ def list_inbound_rejections(inbound_endpoint_id: nil, start_time: nil, end_time: nil, limit: nil, cursor: nil)
339
+ params = {}
340
+ params[:inbound_endpoint_id] = inbound_endpoint_id if inbound_endpoint_id
341
+ params[:start_time] = format_time(start_time) if start_time
342
+ params[:end_time] = format_time(end_time) if end_time
343
+ params[:limit] = limit if limit
344
+ params[:cursor] = cursor if cursor
345
+ response = request(:get, "/v1/inbound-rejections", nil, params)
346
+ InboundRejectionsResponse.new(
347
+ "data" => extract_data(response),
348
+ "has_more" => extract_meta(response)["has_more"] || response["has_more"],
349
+ "next_cursor" => extract_meta(response)["next_cursor"] || response["next_cursor"]
350
+ )
351
+ end
352
+
353
+ def create_export(start_time:, end_time:, status: nil, endpoint_id: nil)
354
+ body = { start_time: format_time(start_time), end_time: format_time(end_time) }
355
+ body[:status] = status if status
356
+ body[:endpoint_id] = endpoint_id if endpoint_id
357
+ ExportRecord.new(extract_data(request(:post, "/v1/exports", body)))
358
+ end
359
+
360
+ def list_exports
361
+ extract_data(request(:get, "/v1/exports")).map { |entry| ExportRecord.new(entry) }
362
+ end
363
+
364
+ def get_export(export_id)
365
+ ExportRecord.new(extract_data(request(:get, "/v1/exports/#{export_id}")))
366
+ end
367
+
368
+ def download_export(export_id)
369
+ request(:get, "/v1/exports/#{export_id}/download", nil, nil, allow_redirect: true)
370
+ end
371
+
168
372
  private
169
373
 
170
- def build_connection
171
- Faraday.new(url: @base_url) do |conn|
374
+ def build_connection(url)
375
+ Faraday.new(url: url) do |conn|
172
376
  conn.request :retry,
173
377
  max: @retries,
174
378
  interval: 0.1,
175
379
  interval_randomness: 0.5,
176
380
  backoff_factor: 2,
177
381
  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
- }
382
+ methods: %i[get post put patch delete]
185
383
 
186
384
  conn.options.timeout = @timeout
187
385
  conn.options.open_timeout = 10
188
-
189
386
  conn.headers["Authorization"] = "Bearer #{@api_key}"
190
387
  conn.headers["Content-Type"] = "application/json"
191
388
  conn.headers["Accept"] = "application/json"
192
389
  conn.headers["User-Agent"] = "hookbridge-ruby/#{VERSION}"
193
-
194
390
  conn.adapter Faraday.default_adapter
195
391
  end
196
392
  end
197
393
 
198
- def request(method, path, body = nil, params = nil)
199
- response = @connection.run_request(method, path, body&.to_json, nil) do |req|
394
+ def request(method, path, body = nil, params = nil, use_send_connection: false, allow_redirect: false)
395
+ conn = use_send_connection ? @send_connection : @connection
396
+ response = conn.run_request(method, path, body&.to_json, nil) do |req|
200
397
  req.params.update(params) if params
201
398
  end
202
399
 
203
- handle_response(response)
400
+ handle_response(response, allow_redirect: allow_redirect)
204
401
  rescue Faraday::TimeoutError => e
205
- raise TimeoutError.new("Request timed out: #{e.message}")
402
+ raise TimeoutError, "Request timed out: #{e.message}"
206
403
  rescue Faraday::ConnectionFailed => e
207
- raise NetworkError.new("Connection failed: #{e.message}")
404
+ raise NetworkError, "Connection failed: #{e.message}"
208
405
  rescue Faraday::Error => e
209
- raise NetworkError.new("Network error: #{e.message}")
406
+ raise NetworkError, "Network error: #{e.message}"
210
407
  end
211
408
 
212
- def handle_response(response)
213
- request_id = response.headers["x-request-id"]
409
+ def handle_response(response, allow_redirect: false)
410
+ return response.headers["location"] if allow_redirect && response.status.between?(300, 399)
411
+ return {} if response.status == 204
214
412
 
215
413
  case response.status
216
414
  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
415
+ return {} if response.body.nil? || response.body.empty?
416
+
417
+ JSON.parse(response.body)
236
418
  when 400
237
- error_data = parse_error(response.body)
238
- raise ValidationError.new(error_data[:message], request_id: request_id)
419
+ error = parse_error(response.body)
420
+ raise ValidationError.new(error[:message], request_id: error[:request_id])
239
421
  when 401
240
- error_data = parse_error(response.body)
241
- raise AuthenticationError.new(error_data[:message], request_id: request_id)
422
+ error = parse_error(response.body)
423
+ raise AuthenticationError.new(error[:message], request_id: error[:request_id])
242
424
  when 404
243
- error_data = parse_error(response.body)
244
- raise NotFoundError.new(error_data[:message], request_id: request_id)
425
+ error = parse_error(response.body)
426
+ raise NotFoundError.new(error[:message], request_id: error[:request_id])
245
427
  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)
428
+ error = parse_error(response.body)
429
+ if %w[IDEMPOTENCY_MISMATCH IDEMPOTENCY_CONFLICT].include?(error[:code])
430
+ raise IdempotencyError.new(error[:message], request_id: error[:request_id])
252
431
  end
432
+ raise ReplayLimitError.new(error[:message], request_id: error[:request_id]) if error[:code] == "REPLAY_LIMIT_EXCEEDED"
433
+
434
+ raise Error.new(error[:message], code: error[:code], request_id: error[:request_id], status_code: 409)
253
435
  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)
436
+ error = parse_error(response.body)
437
+ raise ReplayLimitError.new(error[:message], request_id: error[:request_id]) if error[:code] == "REPLAY_LIMIT_EXCEEDED"
438
+
439
+ raise RateLimitError.new(error[:message], request_id: error[:request_id], retry_after: response.headers["retry-after"]&.to_i)
257
440
  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
- )
441
+ error = parse_error(response.body)
442
+ raise Error.new(error[:message], code: error[:code], request_id: error[:request_id], status_code: response.status)
265
443
  end
266
444
  end
267
445
 
268
446
  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)
447
+ parsed = body && !body.empty? ? JSON.parse(body) : {}
448
+ error_data = parsed["error"]
449
+ if error_data.is_a?(Hash)
274
450
  {
275
- message: data["error"]["message"] || "Unknown error",
276
- code: data["error"]["code"]
451
+ code: error_data["code"],
452
+ message: error_data["message"] || "Request failed",
453
+ request_id: parsed.dig("meta", "request_id")
277
454
  }
278
455
  else
279
456
  {
280
- message: data["error"] || data["message"] || "Unknown error",
281
- code: data["code"]
457
+ code: parsed["code"],
458
+ message: error_data || parsed["message"] || "Request failed",
459
+ request_id: parsed.dig("meta", "request_id")
282
460
  }
283
461
  end
284
462
  rescue JSON::ParserError
285
- { message: body, code: nil }
463
+ { code: nil, message: "Request failed", request_id: nil }
286
464
  end
287
465
 
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
466
+ def extract_data(response)
467
+ response.is_a?(Hash) && response.key?("data") ? response["data"] : response
468
+ end
469
+
470
+ def extract_meta(response)
471
+ response.is_a?(Hash) ? (response["meta"] || {}) : {}
472
+ end
473
+
474
+ def format_time(value)
475
+ value.respond_to?(:iso8601) ? value.iso8601 : value
476
+ end
477
+
478
+ def compact_hash(hash)
479
+ hash.each_with_object({}) do |(key, value), memo|
480
+ memo[key] = value unless value.nil?
296
481
  end
297
482
  end
298
483
  end