hookbridge 1.0.0 → 1.4.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,422 @@ 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 pause_endpoint(endpoint_id)
196
+ PauseState.new(extract_data(request(:post, "/v1/endpoints/#{endpoint_id}/pause")))
197
+ end
198
+
199
+ def resume_endpoint(endpoint_id)
200
+ PauseState.new(extract_data(request(:post, "/v1/endpoints/#{endpoint_id}/resume")))
201
+ end
202
+
203
+ def create_endpoint_signing_key(endpoint_id)
204
+ RotateSecretResponse.new(extract_data(request(:post, "/v1/endpoints/#{endpoint_id}/signing-keys")))
205
+ end
206
+
207
+ def list_endpoint_signing_keys(endpoint_id)
208
+ extract_data(request(:get, "/v1/endpoints/#{endpoint_id}/signing-keys")).map do |entry|
209
+ SigningKey.new(entry)
210
+ end
211
+ end
212
+
213
+ def delete_endpoint_signing_key(endpoint_id, key_id)
214
+ request(:delete, "/v1/endpoints/#{endpoint_id}/signing-keys/#{key_id}")
215
+ true
216
+ end
217
+
218
+ def rotate_endpoint_secret(endpoint_id)
219
+ create_endpoint_signing_key(endpoint_id)
220
+ end
221
+
222
+ def create_checkout(plan:, interval:)
223
+ CheckoutSession.new(extract_data(request(:post, "/v1/billing/checkout", { plan: plan, interval: interval })))
224
+ end
225
+
226
+ def create_portal(return_url: nil)
227
+ body = {}
228
+ body[:return_url] = return_url if return_url
229
+ PortalSession.new(extract_data(request(:post, "/v1/billing/portal", body)))
230
+ end
231
+
232
+ def get_usage_history(limit: 12, offset: 0)
233
+ response = request(:get, "/v1/billing/usage-history", nil, { limit: limit, offset: offset })
234
+ UsageHistoryResponse.new(extract_data(response), extract_meta(response))
235
+ end
236
+
237
+ def get_invoices(limit: 12, starting_after: nil)
238
+ params = { limit: limit }
239
+ params[:starting_after] = starting_after if starting_after
240
+ response = request(:get, "/v1/billing/invoices", nil, params)
241
+ InvoicesResponse.new(extract_data(response), extract_meta(response))
242
+ end
243
+
244
+ 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)
245
+ body = { url: url }
246
+ {
247
+ name: name,
248
+ description: description,
249
+ verify_static_token: verify_static_token,
250
+ token_header_name: token_header_name,
251
+ token_query_param: token_query_param,
252
+ token_value: token_value,
253
+ verify_hmac: verify_hmac,
254
+ hmac_header_name: hmac_header_name,
255
+ hmac_secret: hmac_secret,
256
+ timestamp_header_name: timestamp_header_name,
257
+ timestamp_ttl_seconds: timestamp_ttl_seconds,
258
+ verify_ip_allowlist: verify_ip_allowlist,
259
+ allowed_cidrs: allowed_cidrs,
260
+ idempotency_header_names: idempotency_header_names,
261
+ ingest_response_code: ingest_response_code,
262
+ signing_enabled: signing_enabled
263
+ }.each do |key, value|
264
+ body[key] = value unless value.nil?
265
+ end
266
+ CreateInboundEndpointResponse.new(extract_data(request(:post, "/v1/inbound-endpoints", body)))
267
+ end
268
+
269
+ def list_inbound_endpoints(limit: nil, cursor: nil)
270
+ params = {}
271
+ params[:limit] = limit if limit
272
+ params[:cursor] = cursor if cursor
273
+ response = request(:get, "/v1/inbound-endpoints", nil, params)
274
+ ListInboundEndpointsResponse.new(
275
+ "data" => extract_data(response),
276
+ "has_more" => extract_meta(response)["has_more"] || response["has_more"],
277
+ "next_cursor" => extract_meta(response)["next_cursor"] || response["next_cursor"]
278
+ )
279
+ end
280
+
281
+ def get_inbound_endpoint(endpoint_id)
282
+ InboundEndpoint.new(extract_data(request(:get, "/v1/inbound-endpoints/#{endpoint_id}")))
283
+ end
284
+
285
+ def update_inbound_endpoint(endpoint_id, **attributes)
286
+ UpdateResult.new(extract_data(request(:patch, "/v1/inbound-endpoints/#{endpoint_id}", compact_hash(attributes))))
287
+ end
288
+
289
+ def delete_inbound_endpoint(endpoint_id)
290
+ DeleteResult.new(extract_data(request(:delete, "/v1/inbound-endpoints/#{endpoint_id}")))
291
+ end
292
+
293
+ def pause_inbound_endpoint(endpoint_id)
294
+ PauseState.new(extract_data(request(:post, "/v1/inbound-endpoints/#{endpoint_id}/pause")))
295
+ end
296
+
297
+ def resume_inbound_endpoint(endpoint_id)
298
+ PauseState.new(extract_data(request(:post, "/v1/inbound-endpoints/#{endpoint_id}/resume")))
299
+ end
300
+
301
+ def replay_inbound_message(message_id)
302
+ ReplayResponse.new(extract_data(request(:post, "/v1/inbound-messages/#{message_id}/replay")))
303
+ end
304
+
305
+ def replay_all_inbound_messages(status:, inbound_endpoint_id: nil, limit: nil)
306
+ params = { status: status }
307
+ params[:inbound_endpoint_id] = inbound_endpoint_id if inbound_endpoint_id
308
+ params[:limit] = limit if limit
309
+ ReplayAllMessagesResponse.new(extract_data(request(:post, "/v1/inbound-messages/replay-all", nil, params)))
310
+ end
311
+
312
+ def replay_batch_inbound_messages(message_ids)
313
+ ReplayBatchMessagesResponse.new(
314
+ extract_data(request(:post, "/v1/inbound-messages/replay-batch", { message_ids: message_ids }))
315
+ )
316
+ end
317
+
318
+ def get_inbound_logs(status: nil, inbound_endpoint_id: nil, start_time: nil, end_time: nil, limit: nil, cursor: nil)
319
+ params = {}
320
+ params[:status] = status if status
321
+ params[:inbound_endpoint_id] = inbound_endpoint_id if inbound_endpoint_id
322
+ params[:start_time] = format_time(start_time) if start_time
323
+ params[:end_time] = format_time(end_time) if end_time
324
+ params[:limit] = limit if limit
325
+ params[:cursor] = cursor if cursor
326
+ response = request(:get, "/v1/inbound-logs", nil, params)
327
+ InboundLogsResponse.new(
328
+ "data" => extract_data(response),
329
+ "has_more" => extract_meta(response)["has_more"] || response["has_more"],
330
+ "next_cursor" => extract_meta(response)["next_cursor"] || response["next_cursor"]
331
+ )
332
+ end
333
+
334
+ def get_inbound_metrics(window: MetricsWindow::TWENTY_FOUR_HOURS, inbound_endpoint_id: nil)
335
+ params = { window: window }
336
+ params[:inbound_endpoint_id] = inbound_endpoint_id if inbound_endpoint_id
337
+ InboundMetrics.new(extract_data(request(:get, "/v1/inbound-metrics", nil, params)))
338
+ end
339
+
340
+ def get_inbound_timeseries_metrics(window: MetricsWindow::TWENTY_FOUR_HOURS, inbound_endpoint_id: nil)
341
+ params = { window: window }
342
+ params[:inbound_endpoint_id] = inbound_endpoint_id if inbound_endpoint_id
343
+ TimeSeriesMetrics.new(extract_data(request(:get, "/v1/inbound-metrics/timeseries", nil, params)))
344
+ end
345
+
346
+ def list_inbound_rejections(inbound_endpoint_id: nil, start_time: nil, end_time: nil, limit: nil, cursor: nil)
347
+ params = {}
348
+ params[:inbound_endpoint_id] = inbound_endpoint_id if inbound_endpoint_id
349
+ params[:start_time] = format_time(start_time) if start_time
350
+ params[:end_time] = format_time(end_time) if end_time
351
+ params[:limit] = limit if limit
352
+ params[:cursor] = cursor if cursor
353
+ response = request(:get, "/v1/inbound-rejections", nil, params)
354
+ InboundRejectionsResponse.new(
355
+ "data" => extract_data(response),
356
+ "has_more" => extract_meta(response)["has_more"] || response["has_more"],
357
+ "next_cursor" => extract_meta(response)["next_cursor"] || response["next_cursor"]
358
+ )
359
+ end
360
+
361
+ def create_export(start_time:, end_time:, status: nil, endpoint_id: nil)
362
+ body = { start_time: format_time(start_time), end_time: format_time(end_time) }
363
+ body[:status] = status if status
364
+ body[:endpoint_id] = endpoint_id if endpoint_id
365
+ ExportRecord.new(extract_data(request(:post, "/v1/exports", body)))
366
+ end
367
+
368
+ def list_exports
369
+ extract_data(request(:get, "/v1/exports")).map { |entry| ExportRecord.new(entry) }
370
+ end
371
+
372
+ def get_export(export_id)
373
+ ExportRecord.new(extract_data(request(:get, "/v1/exports/#{export_id}")))
374
+ end
375
+
376
+ def download_export(export_id)
377
+ request(:get, "/v1/exports/#{export_id}/download", nil, nil, allow_redirect: true)
378
+ end
379
+
168
380
  private
169
381
 
170
- def build_connection
171
- Faraday.new(url: @base_url) do |conn|
382
+ def build_connection(url)
383
+ Faraday.new(url: url) do |conn|
172
384
  conn.request :retry,
173
385
  max: @retries,
174
386
  interval: 0.1,
175
387
  interval_randomness: 0.5,
176
388
  backoff_factor: 2,
177
389
  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
- }
390
+ methods: %i[get post put patch delete]
185
391
 
186
392
  conn.options.timeout = @timeout
187
393
  conn.options.open_timeout = 10
188
-
189
394
  conn.headers["Authorization"] = "Bearer #{@api_key}"
190
395
  conn.headers["Content-Type"] = "application/json"
191
396
  conn.headers["Accept"] = "application/json"
192
397
  conn.headers["User-Agent"] = "hookbridge-ruby/#{VERSION}"
193
-
194
398
  conn.adapter Faraday.default_adapter
195
399
  end
196
400
  end
197
401
 
198
- def request(method, path, body = nil, params = nil)
199
- response = @connection.run_request(method, path, body&.to_json, nil) do |req|
402
+ def request(method, path, body = nil, params = nil, use_send_connection: false, allow_redirect: false)
403
+ conn = use_send_connection ? @send_connection : @connection
404
+ response = conn.run_request(method, path, body&.to_json, nil) do |req|
200
405
  req.params.update(params) if params
201
406
  end
202
407
 
203
- handle_response(response)
408
+ handle_response(response, allow_redirect: allow_redirect)
204
409
  rescue Faraday::TimeoutError => e
205
- raise TimeoutError.new("Request timed out: #{e.message}")
410
+ raise TimeoutError, "Request timed out: #{e.message}"
206
411
  rescue Faraday::ConnectionFailed => e
207
- raise NetworkError.new("Connection failed: #{e.message}")
412
+ raise NetworkError, "Connection failed: #{e.message}"
208
413
  rescue Faraday::Error => e
209
- raise NetworkError.new("Network error: #{e.message}")
414
+ raise NetworkError, "Network error: #{e.message}"
210
415
  end
211
416
 
212
- def handle_response(response)
213
- request_id = response.headers["x-request-id"]
417
+ def handle_response(response, allow_redirect: false)
418
+ return response.headers["location"] if allow_redirect && response.status.between?(300, 399)
419
+ return {} if response.status == 204
214
420
 
215
421
  case response.status
216
422
  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
423
+ return {} if response.body.nil? || response.body.empty?
424
+
425
+ JSON.parse(response.body)
236
426
  when 400
237
- error_data = parse_error(response.body)
238
- raise ValidationError.new(error_data[:message], request_id: request_id)
427
+ error = parse_error(response.body)
428
+ raise ValidationError.new(error[:message], request_id: error[:request_id])
239
429
  when 401
240
- error_data = parse_error(response.body)
241
- raise AuthenticationError.new(error_data[:message], request_id: request_id)
430
+ error = parse_error(response.body)
431
+ raise AuthenticationError.new(error[:message], request_id: error[:request_id])
242
432
  when 404
243
- error_data = parse_error(response.body)
244
- raise NotFoundError.new(error_data[:message], request_id: request_id)
433
+ error = parse_error(response.body)
434
+ raise NotFoundError.new(error[:message], request_id: error[:request_id])
245
435
  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)
436
+ error = parse_error(response.body)
437
+ if %w[IDEMPOTENCY_MISMATCH IDEMPOTENCY_CONFLICT].include?(error[:code])
438
+ raise IdempotencyError.new(error[:message], request_id: error[:request_id])
252
439
  end
440
+ raise ReplayLimitError.new(error[:message], request_id: error[:request_id]) if error[:code] == "REPLAY_LIMIT_EXCEEDED"
441
+
442
+ raise Error.new(error[:message], code: error[:code], request_id: error[:request_id], status_code: 409)
253
443
  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)
444
+ error = parse_error(response.body)
445
+ raise ReplayLimitError.new(error[:message], request_id: error[:request_id]) if error[:code] == "REPLAY_LIMIT_EXCEEDED"
446
+
447
+ raise RateLimitError.new(error[:message], request_id: error[:request_id], retry_after: response.headers["retry-after"]&.to_i)
257
448
  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
- )
449
+ error = parse_error(response.body)
450
+ raise Error.new(error[:message], code: error[:code], request_id: error[:request_id], status_code: response.status)
265
451
  end
266
452
  end
267
453
 
268
454
  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)
455
+ parsed = body && !body.empty? ? JSON.parse(body) : {}
456
+ error_data = parsed["error"]
457
+ if error_data.is_a?(Hash)
274
458
  {
275
- message: data["error"]["message"] || "Unknown error",
276
- code: data["error"]["code"]
459
+ code: error_data["code"],
460
+ message: error_data["message"] || "Request failed",
461
+ request_id: parsed.dig("meta", "request_id")
277
462
  }
278
463
  else
279
464
  {
280
- message: data["error"] || data["message"] || "Unknown error",
281
- code: data["code"]
465
+ code: parsed["code"],
466
+ message: error_data || parsed["message"] || "Request failed",
467
+ request_id: parsed.dig("meta", "request_id")
282
468
  }
283
469
  end
284
470
  rescue JSON::ParserError
285
- { message: body, code: nil }
471
+ { code: nil, message: "Request failed", request_id: nil }
286
472
  end
287
473
 
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
474
+ def extract_data(response)
475
+ response.is_a?(Hash) && response.key?("data") ? response["data"] : response
476
+ end
477
+
478
+ def extract_meta(response)
479
+ response.is_a?(Hash) ? (response["meta"] || {}) : {}
480
+ end
481
+
482
+ def format_time(value)
483
+ value.respond_to?(:iso8601) ? value.iso8601 : value
484
+ end
485
+
486
+ def compact_hash(hash)
487
+ hash.each_with_object({}) do |(key, value), memo|
488
+ memo[key] = value unless value.nil?
296
489
  end
297
490
  end
298
491
  end