inttegro 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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/.dockerignore +9 -0
  3. data/CHANGELOG.md +17 -0
  4. data/Dockerfile +23 -0
  5. data/LICENSE +21 -0
  6. data/README.md +104 -0
  7. data/Rakefile +50 -0
  8. data/lib/inttegro/client.rb +159 -0
  9. data/lib/inttegro/enums.rb +6 -0
  10. data/lib/inttegro/errors.rb +128 -0
  11. data/lib/inttegro/file_download.rb +29 -0
  12. data/lib/inttegro/generated/enums.rb +693 -0
  13. data/lib/inttegro/generated/models.rb +3840 -0
  14. data/lib/inttegro/generated/operations.rb +369 -0
  15. data/lib/inttegro/http_client.rb +639 -0
  16. data/lib/inttegro/models.rb +132 -0
  17. data/lib/inttegro/resources/apps.rb +24 -0
  18. data/lib/inttegro/resources/balance_transactions.rb +28 -0
  19. data/lib/inttegro/resources/balances.rb +16 -0
  20. data/lib/inttegro/resources/broadcasts.rb +28 -0
  21. data/lib/inttegro/resources/chimes.rb +32 -0
  22. data/lib/inttegro/resources/customers.rb +28 -0
  23. data/lib/inttegro/resources/file_links.rb +50 -0
  24. data/lib/inttegro/resources/file_references.rb +20 -0
  25. data/lib/inttegro/resources/files.rb +39 -0
  26. data/lib/inttegro/resources/financial_accounts.rb +104 -0
  27. data/lib/inttegro/resources/keys.rb +60 -0
  28. data/lib/inttegro/resources/message_templates.rb +78 -0
  29. data/lib/inttegro/resources/orders.rb +422 -0
  30. data/lib/inttegro/resources/otp.rb +30 -0
  31. data/lib/inttegro/resources/payment_methods.rb +250 -0
  32. data/lib/inttegro/resources/payouts.rb +200 -0
  33. data/lib/inttegro/resources/prices.rb +40 -0
  34. data/lib/inttegro/resources/products.rb +48 -0
  35. data/lib/inttegro/resources/purchase_intents.rb +44 -0
  36. data/lib/inttegro/resources/refunds.rb +36 -0
  37. data/lib/inttegro/resources/schedules.rb +28 -0
  38. data/lib/inttegro/resources/spec.rb +16 -0
  39. data/lib/inttegro/resources/upload_requests.rb +66 -0
  40. data/lib/inttegro/response_object.rb +86 -0
  41. data/lib/inttegro/transport_response.rb +65 -0
  42. data/lib/inttegro/types.rb +32 -0
  43. data/lib/inttegro/version.rb +6 -0
  44. data/lib/inttegro.rb +14 -0
  45. data/rbi/inttegro/generated.rbi +529 -0
  46. metadata +107 -0
@@ -0,0 +1,639 @@
1
+ # frozen_string_literal: true
2
+ # typed: strict
3
+
4
+ require "json"
5
+ require "net/http"
6
+ require "securerandom"
7
+ require "uri"
8
+
9
+ require_relative "errors"
10
+ require_relative "file_download"
11
+ require_relative "models"
12
+ require_relative "generated/operations"
13
+ require_relative "response_object"
14
+ require_relative "transport_response"
15
+ require_relative "types"
16
+ require_relative "version"
17
+
18
+ module Inttegro
19
+ DEFAULT_BASE_URL = T.let("https://api.inttegro.com", String)
20
+
21
+ # Minimal HTTP client that wraps Net::HTTP with JSON parsing and API-friendly errors.
22
+ class HTTPClient
23
+ extend T::Sig
24
+
25
+ USER_AGENT = T.let("inttegro-sdk-ruby/#{Inttegro::VERSION}", String)
26
+ ResponseValue = T.type_alias do
27
+ T.any(T::Struct, ResponseObject, Types::Scalar, T::Array[Object])
28
+ end
29
+ ErrorPayload = T.type_alias { T::Hash[Symbol, T.nilable(String)] }
30
+
31
+ sig do
32
+ params(
33
+ api_key: T.nilable(String),
34
+ base_url: T.nilable(String),
35
+ read_timeout: T.nilable(Numeric),
36
+ open_timeout: T.nilable(Numeric),
37
+ adapter: T.nilable(Types::Adapter)
38
+ ).void
39
+ end
40
+ def initialize(api_key:, base_url: DEFAULT_BASE_URL, read_timeout: 30, open_timeout: 10, adapter: nil)
41
+ @api_key = T.let(api_key || "", String)
42
+ raise ArgumentError, "api_key is required" if @api_key.strip.empty?
43
+
44
+ @base_url = T.let(base_url&.sub(%r{/*$}, "") || DEFAULT_BASE_URL, String)
45
+ @read_timeout = T.let(read_timeout, T.nilable(Numeric))
46
+ @open_timeout = T.let(open_timeout, T.nilable(Numeric))
47
+ @adapter = T.let(adapter, T.nilable(Types::Adapter))
48
+ end
49
+
50
+ sig { params(path: String, headers: Types::Headers, query: T.nilable(Types::Query)).returns(ResponseValue) }
51
+ def get(path, headers: {}, query: nil)
52
+ request(:get, path, headers: headers, query: query)
53
+ end
54
+
55
+ sig do
56
+ params(
57
+ path: String,
58
+ body: T.nilable(Types::RequestBody),
59
+ headers: Types::Headers,
60
+ query: T.nilable(Types::Query),
61
+ body_keywords: Object
62
+ ).returns(ResponseValue)
63
+ end
64
+ def post(path, body = nil, headers: {}, query: nil, **body_keywords)
65
+ body = body_keywords unless body_keywords.empty?
66
+ request(:post, path, body: body, headers: headers, query: query)
67
+ end
68
+
69
+ sig do
70
+ type_parameters(:Model)
71
+ .params(
72
+ path: String,
73
+ model: T::Class[T.type_parameter(:Model)],
74
+ body: Types::RequestBody,
75
+ headers: Types::Headers,
76
+ query: T.nilable(Types::Query)
77
+ )
78
+ .returns(T.type_parameter(:Model))
79
+ end
80
+ def post_model(path, model, body, headers: {}, query: nil)
81
+ unless model <= T::Struct
82
+ raise TypeError, "response model for #{path} must inherit from T::Struct"
83
+ end
84
+
85
+ response = request(
86
+ :post,
87
+ path,
88
+ body: body,
89
+ headers: headers,
90
+ query: query,
91
+ response_model: model
92
+ )
93
+ return T.cast(response, T.type_parameter(:Model)) if response.is_a?(model)
94
+
95
+ raise TypeError, "expected #{model} from #{path}, got #{response.class}"
96
+ end
97
+
98
+ sig do
99
+ params(
100
+ path: String,
101
+ body: Types::RequestBody,
102
+ headers: Types::Headers,
103
+ query: T.nilable(Types::Query)
104
+ ).returns(ResponseObject)
105
+ end
106
+ def post_object(path, body, headers: {}, query: nil)
107
+ expect_response_object(request(:post, path, body: body, headers: headers, query: query), path)
108
+ end
109
+
110
+ sig do
111
+ params(path: String, body: Types::RequestBody, headers: Types::Headers).returns(ResponseObject)
112
+ end
113
+ def post_with_headers(path, body, headers: {})
114
+ post_object(path, body, headers: headers)
115
+ end
116
+
117
+ sig do
118
+ params(
119
+ path: String,
120
+ fields: Types::Payload,
121
+ files: Types::Payload,
122
+ headers: Types::Headers,
123
+ authenticated: T::Boolean
124
+ ).returns(ResponseObject)
125
+ end
126
+ def post_multipart(path, fields: {}, files: {}, headers: {}, authenticated: true)
127
+ expect_response_object(
128
+ perform_multipart(path, fields, files, headers, authenticated, nil),
129
+ path
130
+ )
131
+ end
132
+
133
+ sig do
134
+ type_parameters(:Model)
135
+ .params(
136
+ path: String,
137
+ model: T::Class[T.type_parameter(:Model)],
138
+ fields: Types::Payload,
139
+ files: Types::Payload,
140
+ headers: Types::Headers,
141
+ authenticated: T::Boolean
142
+ )
143
+ .returns(T.type_parameter(:Model))
144
+ end
145
+ def post_multipart_model(path, model, fields: {}, files: {}, headers: {}, authenticated: true)
146
+ unless model <= T::Struct
147
+ raise TypeError, "response model for #{path} must inherit from T::Struct"
148
+ end
149
+
150
+ response = perform_multipart(path, fields, files, headers, authenticated, model)
151
+ return T.cast(response, T.type_parameter(:Model)) if response.is_a?(model)
152
+
153
+ raise TypeError, "expected #{model} from #{path}, got #{response.class}"
154
+ end
155
+
156
+ sig do
157
+ params(
158
+ path: String,
159
+ fields: Types::Payload,
160
+ files: Types::Payload,
161
+ headers: Types::Headers,
162
+ authenticated: T::Boolean,
163
+ model: T.nilable(T::Class[T::Struct])
164
+ ).returns(ResponseValue)
165
+ end
166
+ def perform_multipart(path, fields, files, headers, authenticated, model)
167
+ uri = build_uri(path, nil)
168
+ request = Net::HTTP::Post.new(uri)
169
+ request["Accept"] = "application/json"
170
+ request["User-Agent"] = USER_AGENT
171
+ request["Authorization"] = "Bearer #{@api_key}" if authenticated
172
+ headers = headers.dup
173
+ if authenticated && idempotent_mutation_path?(path) && !header_present?(headers, "Idempotency-Key")
174
+ headers["Idempotency-Key"] = generate_idempotency_key
175
+ end
176
+ headers.each { |key, value| request[key] = value }
177
+
178
+ form = []
179
+ fields.each do |key, value|
180
+ next if value.nil?
181
+
182
+ form << [key.to_s, value.is_a?(Hash) || value.is_a?(Array) ? JSON.generate(value) : value.to_s]
183
+ end
184
+ files.each do |key, path|
185
+ raise ArgumentError, "file path for #{key} must be a String" unless path.is_a?(String)
186
+
187
+ form << [key.to_s, File.open(path)]
188
+ end
189
+ request.set_form(form, "multipart/form-data")
190
+
191
+ raw_response = @adapter ? @adapter.call(uri, request) : build_http(uri).request(request)
192
+ response = TransportResponse.new(raw_response)
193
+ handle_response(path, response, model)
194
+ end
195
+
196
+ sig { params(path: String, body: Types::RequestBody).returns(FileDownload) }
197
+ def post_binary_json(path, body)
198
+ body = with_request_meta_idempotency(:post, path, coerce_body(body), {})
199
+ response = raw_request(:post, path, JSON.dump(body), {
200
+ "Accept" => "application/octet-stream",
201
+ "Content-Type" => "application/json",
202
+ "Authorization" => "Bearer #{@api_key}"
203
+ })
204
+ return FileDownload.new(response.body, response.headers) if response.code.to_i < 400
205
+
206
+ raise_response_error(response)
207
+ end
208
+
209
+ sig { params(url: String).returns(FileDownload) }
210
+ def get_binary_public(url)
211
+ uri = build_uri(url, nil)
212
+ request = Net::HTTP::Get.new(uri)
213
+ request["User-Agent"] = USER_AGENT
214
+ raw_response = @adapter ? @adapter.call(uri, request) : build_http(uri).request(request)
215
+ response = TransportResponse.new(raw_response)
216
+ return FileDownload.new(response.body, response.headers) if response.code.to_i < 400
217
+
218
+ raise_response_error(response)
219
+ end
220
+
221
+ sig do
222
+ params(
223
+ method: T.any(String, Symbol),
224
+ path: String,
225
+ body: T.nilable(Types::RequestBody),
226
+ headers: Types::Headers,
227
+ query: T.nilable(Types::Query),
228
+ response_model: T.nilable(T::Class[T::Struct])
229
+ ).returns(ResponseValue)
230
+ end
231
+ def request(method, path, body: nil, headers: {}, query: nil, response_model: nil)
232
+ uri = build_uri(path, query)
233
+ body = validate_body(path, coerce_body(body))
234
+ body = with_request_meta_idempotency(method, path, body, headers)
235
+ request = build_request(method, uri, body, headers)
236
+
237
+ raw_response =
238
+ if @adapter
239
+ @adapter.call(uri, request)
240
+ else
241
+ http = build_http(uri)
242
+ http.request(request)
243
+ end
244
+ response = TransportResponse.new(raw_response)
245
+ handle_response(path, response, response_model)
246
+ rescue Timeout::Error, Errno::ETIMEDOUT => e
247
+ raise TimeoutError.new("Request timed out", e)
248
+ rescue SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET => e
249
+ raise NetworkError.new("Network request failed", e)
250
+ end
251
+
252
+ private
253
+
254
+ sig { params(path: String, query: T.nilable(Types::Query)).returns(URI::Generic) }
255
+ def build_uri(path, query)
256
+ if path.start_with?("http://", "https://")
257
+ uri = URI.parse(path)
258
+ uri.query = URI.encode_www_form(query) if query && !query.empty?
259
+ return uri
260
+ end
261
+ normalized = path.start_with?("/") ? path : "/#{path}"
262
+ uri = URI.parse(@base_url + normalized)
263
+ uri.query = URI.encode_www_form(query) if query && !query.empty?
264
+ uri
265
+ end
266
+
267
+ sig do
268
+ params(
269
+ method: T.any(String, Symbol),
270
+ path: String,
271
+ body: T.nilable(String),
272
+ headers: Types::Headers
273
+ ).returns(TransportResponse)
274
+ end
275
+ def raw_request(method, path, body, headers)
276
+ uri = build_uri(path, nil)
277
+ request = build_request(method, uri, nil, headers)
278
+ request.body = body if body
279
+ raw_response = @adapter ? @adapter.call(uri, request) : build_http(uri).request(request)
280
+ TransportResponse.new(raw_response)
281
+ end
282
+
283
+ sig { params(uri: URI::Generic).returns(Net::HTTP) }
284
+ def build_http(uri)
285
+ http = Net::HTTP.new(uri.host, uri.port)
286
+ http.use_ssl = uri.scheme == "https"
287
+ http.read_timeout = @read_timeout if @read_timeout
288
+ http.open_timeout = @open_timeout if @open_timeout
289
+ http
290
+ end
291
+
292
+ sig do
293
+ params(
294
+ method: T.any(String, Symbol),
295
+ uri: URI::Generic,
296
+ body: T.nilable(Types::Payload),
297
+ headers: Types::Headers
298
+ ).returns(Net::HTTPRequest)
299
+ end
300
+ def build_request(method, uri, body, headers)
301
+ klass =
302
+ case method.to_s.downcase
303
+ when "get" then Net::HTTP::Get
304
+ when "post" then Net::HTTP::Post
305
+ when "delete" then Net::HTTP::Delete
306
+ when "put" then Net::HTTP::Put
307
+ when "patch" then Net::HTTP::Patch
308
+ else
309
+ raise ArgumentError, "Unsupported HTTP method: #{method}"
310
+ end
311
+
312
+ req = klass.new(uri)
313
+ req["Accept"] = "application/json"
314
+ req["Authorization"] = "Bearer #{@api_key}"
315
+ req["User-Agent"] = USER_AGENT
316
+
317
+ headers.each { |key, value| req[key] = value }
318
+
319
+ if body
320
+ req["Content-Type"] ||= "application/json"
321
+ req.body = JSON.dump(body)
322
+ end
323
+
324
+ req
325
+ end
326
+
327
+ sig do
328
+ params(
329
+ method: T.any(String, Symbol),
330
+ path: String,
331
+ body: T.nilable(Types::Payload),
332
+ headers: Types::Headers
333
+ ).returns(T.nilable(Types::Payload))
334
+ end
335
+ def with_request_meta_idempotency(method, path, body, headers)
336
+ return body unless body.is_a?(Hash)
337
+
338
+ payload = body.dup
339
+ payload.delete(:idempotency_key)
340
+ payload.delete("idempotency_key")
341
+ return payload unless method.to_s.downcase == "post" && idempotent_mutation_path?(path)
342
+ return payload if header_present?(headers, "Idempotency-Key")
343
+
344
+ request_meta_value = payload.delete(:request_meta) || payload.delete("request_meta")
345
+ request_meta = request_meta_value.is_a?(Hash) ? request_meta_value.dup : {}
346
+ existing = request_meta[:idempotency_key] || request_meta["idempotency_key"]
347
+ request_meta["idempotency_key"] = generate_idempotency_key if existing.to_s.strip.empty?
348
+ payload["request_meta"] = request_meta
349
+ payload
350
+ end
351
+
352
+ sig { params(path_or_url: String).returns(T::Boolean) }
353
+ def idempotent_mutation_path?(path_or_url)
354
+ path =
355
+ if path_or_url.start_with?("http://", "https://")
356
+ URI.parse(path_or_url).path || ""
357
+ else
358
+ path_or_url
359
+ end
360
+ action = path.split("/").reject(&:empty?).last
361
+ !["", nil, "lookup", "page", "settings", "countries", "contents", "balances", "render_preview"].include?(action)
362
+ rescue URI::InvalidURIError
363
+ false
364
+ end
365
+
366
+ sig { params(headers: Types::Headers, name: String).returns(T::Boolean) }
367
+ def header_present?(headers, name)
368
+ headers.any? { |key, value| key.to_s.casecmp?(name) && !value.to_s.strip.empty? }
369
+ end
370
+
371
+ sig { returns(String) }
372
+ def generate_idempotency_key
373
+ random = SecureRandom.bytes(10).bytes
374
+ timestamp = (Time.now.to_f * 1000).to_i & ((1 << 48) - 1)
375
+ rand_a = ((random[0] << 8) | random[1]) & 0x0fff
376
+ rand_b = random[2..].reduce(0) { |value, byte| (value << 8) | byte } & ((1 << 62) - 1)
377
+ value = (timestamp << 80) | (0x7 << 76) | (rand_a << 64) | (0x2 << 62) | rand_b
378
+ hex = value.to_s(16).rjust(32, "0")
379
+ "#{hex[0, 8]}-#{hex[8, 4]}-#{hex[12, 4]}-#{hex[16, 4]}-#{hex[20, 12]}"
380
+ end
381
+
382
+ sig do
383
+ params(
384
+ path: String,
385
+ response: TransportResponse,
386
+ model_class: T.nilable(T::Class[T::Struct])
387
+ ).returns(ResponseValue)
388
+ end
389
+ def handle_response(path, response, model_class = nil)
390
+ status = response.code.to_i
391
+ data = parse_json(response.body)
392
+
393
+ return wrap_response(data, model_class || response_model_for(path)) if status < 400
394
+
395
+ raise_response_error(response)
396
+ end
397
+
398
+ sig { params(response: TransportResponse).returns(T.noreturn) }
399
+ def raise_response_error(response)
400
+ status = response.code.to_i
401
+ body = response.body
402
+ data = parse_json(body)
403
+ payload = extract_error_payload(data)
404
+ message = payload[:message] || payload[:detail] || extract_error_message(data, response)
405
+ code = payload[:code]
406
+ type = payload[:type]
407
+ url = payload[:url]
408
+ detail = payload[:detail]
409
+ fix_code = payload[:fix_code]
410
+ cause = payload[:cause]
411
+
412
+ case status
413
+ when 401
414
+ raise AuthenticationError.new(
415
+ message,
416
+ status: status,
417
+ code: code,
418
+ type: type,
419
+ url: url,
420
+ detail: detail,
421
+ fix_code: fix_code,
422
+ cause: cause,
423
+ body: body,
424
+ data: data
425
+ )
426
+ when 429
427
+ retry_after = response["Retry-After"]&.to_i
428
+ raise RateLimitError.new(
429
+ message,
430
+ status: status,
431
+ code: code,
432
+ type: type,
433
+ url: url,
434
+ detail: detail,
435
+ fix_code: fix_code,
436
+ cause: cause,
437
+ body: body,
438
+ data: data,
439
+ retry_after: retry_after
440
+ )
441
+ else
442
+ raise APIError.new(
443
+ message,
444
+ status: status,
445
+ code: code,
446
+ type: type,
447
+ url: url,
448
+ detail: detail,
449
+ fix_code: fix_code,
450
+ cause: cause,
451
+ body: body,
452
+ data: data
453
+ )
454
+ end
455
+ end
456
+
457
+ sig { params(body: String).returns(Types::WireValue) }
458
+ def parse_json(body)
459
+ return {} if body.empty?
460
+
461
+ normalize_wire_value(JSON.parse(body))
462
+ rescue JSON::ParserError
463
+ body
464
+ end
465
+
466
+ sig { params(data: Object, response: TransportResponse).returns(String) }
467
+ def extract_error_message(data, response)
468
+ return "HTTP #{response.code} #{response.message}" unless data.is_a?(Hash)
469
+
470
+ error = data["error"]
471
+ candidate = error.is_a?(Hash) ? error["message"] : data["message"] || error
472
+ candidate.is_a?(String) && !candidate.empty? ? candidate : "HTTP #{response.code}"
473
+ end
474
+
475
+ sig { params(data: Object).returns(ErrorPayload) }
476
+ def extract_error_payload(data)
477
+ return {} unless data.is_a?(Hash)
478
+
479
+ payload = data["error"].is_a?(Hash) ? data["error"] : data
480
+ {
481
+ message: string_or_nil(payload["message"]),
482
+ detail: string_or_nil(payload["detail"]),
483
+ code: string_or_nil(payload["code"]),
484
+ type: string_or_nil(payload["type"]),
485
+ url: string_or_nil(payload["url"]),
486
+ fix_code: string_or_nil(payload["fix_code"]),
487
+ cause: string_or_nil(payload["cause"])
488
+ }
489
+ end
490
+
491
+ sig do
492
+ params(data: Object, model_class: T.nilable(T::Class[T::Struct])).returns(ResponseValue)
493
+ end
494
+ def wrap_response(data, model_class = nil)
495
+ case data
496
+ when Hash
497
+ payload = normalize_payload(data)
498
+ model_class ? Inttegro::Models.deserialize(payload, model_class) : ResponseObject.new(payload)
499
+ when Array
500
+ data.each_with_object(T.let([], T::Array[Object])) do |item, values|
501
+ values << wrap_response(item, model_class)
502
+ end
503
+ when String, Integer, Float, TrueClass, FalseClass, NilClass
504
+ data
505
+ else
506
+ raise TypeError, "unsupported response value: #{data.class}"
507
+ end
508
+ end
509
+
510
+ sig { params(value: T.nilable(Types::RequestBody)).returns(T.nilable(Types::Payload)) }
511
+ def coerce_body(value)
512
+ return nil if value.nil?
513
+
514
+ normalize_payload(value.is_a?(T::Struct) ? value.serialize : value)
515
+ end
516
+
517
+ sig { params(path: String).returns(T.nilable(T::Class[T::Struct])) }
518
+ def response_model_for(path)
519
+ return Inttegro::Models::CancelOTPResponse if path == "/otp/cancel"
520
+ return Inttegro::Models::OrderResponse if path == "/orders/new"
521
+
522
+ Inttegro::Operations::RESPONSE_MODELS[path]
523
+ end
524
+
525
+ sig do
526
+ params(path: String, body: T.nilable(Types::Payload)).returns(T.nilable(Types::Payload))
527
+ end
528
+ def validate_body(path, body)
529
+ return body unless body.is_a?(Hash)
530
+
531
+ case path
532
+ when "/financial_accounts/create", "/financial_accounts/connect"
533
+ require_keys(body, %w[label type reference currency owner], path)
534
+ when "/financial_accounts/lookup"
535
+ require_keys(body, %w[account_id], path)
536
+ when "/customers/create"
537
+ require_keys(body, %w[name], path)
538
+ when "/customers/lookup"
539
+ require_keys(body, %w[customer_id], path)
540
+ when "/products/create"
541
+ require_keys(body, %w[type name], path)
542
+ when "/products/add_price"
543
+ require_keys(body, %w[product_id amount], path)
544
+ when "/products/set_default_unit_price"
545
+ require_keys(body, %w[product_id price_id], path)
546
+ when "/products/lookup", "/products/update", "/products/publish", "/products/unpublish", "/products/archive"
547
+ require_keys(body, %w[product_id], path)
548
+ when "/orders/create", "/orders/new"
549
+ require_any(body, %w[customer_data customer_id], path)
550
+ require_keys(body, %w[line_items], path)
551
+ when "/orders/lookup", "/orders/update", "/orders/request_confirmation", "/orders/finalize",
552
+ "/orders/cancel", "/orders/refund", "/orders/complete"
553
+ require_keys(body, %w[order_id], path)
554
+ when "/orders/confirm_payment"
555
+ require_keys(body, %w[order_id token], path)
556
+ when "/orders/pay"
557
+ require_keys(body, %w[order_id], path)
558
+ when "/payment_methods/tokenize"
559
+ require_keys(body, %w[customer_id payment_method_data], path)
560
+ when "/payment_methods/verify", "/payment_methods/confirm_verification",
561
+ "/payment_methods/lookup", "/payment_methods/delete"
562
+ require_keys(body, %w[payment_method_id], path)
563
+ when "/payouts/set_destinations"
564
+ require_keys(body, %w[destinations], path)
565
+ when "/payouts/cancel"
566
+ require_keys(body, %w[payout_id], path)
567
+ end
568
+
569
+ body
570
+ end
571
+
572
+ sig { params(body: Types::Payload, keys: T::Array[String], path: String).void }
573
+ def require_keys(body, keys, path)
574
+ missing = keys.reject { |key| present?(body[key]) || present?(body[key.to_sym]) }
575
+ return if missing.empty?
576
+
577
+ raise ArgumentError, "Missing required fields for #{path}: #{missing.join(', ')}"
578
+ end
579
+
580
+ sig { params(body: Types::Payload, keys: T::Array[String], path: String).void }
581
+ def require_any(body, keys, path)
582
+ present = keys.any? { |key| present?(body[key]) || present?(body[key.to_sym]) }
583
+ return if present
584
+
585
+ raise ArgumentError, "Missing required fields for #{path}: one of #{keys.join(', ')}"
586
+ end
587
+
588
+ sig { params(value: Object).returns(T::Boolean) }
589
+ def present?(value)
590
+ return false if value.nil?
591
+ return !value.empty? if value.is_a?(String) || value.is_a?(Array) || value.is_a?(Hash)
592
+
593
+ true
594
+ end
595
+
596
+ sig { params(value: Object).returns(T.nilable(String)) }
597
+ def string_or_nil(value)
598
+ value.is_a?(String) ? value : nil
599
+ end
600
+
601
+ sig { params(value: Object).returns(Types::Payload) }
602
+ def normalize_payload(value)
603
+ raise TypeError, "request and response payloads must be hashes" unless value.is_a?(Hash)
604
+
605
+ value.each_with_object({}) do |(key, item), output|
606
+ unless key.is_a?(String) || key.is_a?(Symbol)
607
+ raise TypeError, "payload keys must be strings or symbols, got #{key.class}"
608
+ end
609
+
610
+ output[key] = normalize_wire_value(item)
611
+ end
612
+ end
613
+
614
+ sig { params(value: Object).returns(Types::WireValue) }
615
+ def normalize_wire_value(value)
616
+ case value
617
+ when T::Struct
618
+ normalize_payload(value.serialize)
619
+ when T::Enum
620
+ value.serialize
621
+ when Hash
622
+ normalize_payload(value)
623
+ when Array
624
+ value.map { |item| normalize_wire_value(item) }
625
+ when String, Integer, Float, TrueClass, FalseClass, NilClass
626
+ value
627
+ else
628
+ raise TypeError, "unsupported JSON value: #{value.class}"
629
+ end
630
+ end
631
+
632
+ sig { params(value: ResponseValue, path: String).returns(ResponseObject) }
633
+ def expect_response_object(value, path)
634
+ return value if value.is_a?(ResponseObject)
635
+
636
+ raise TypeError, "expected an object response from #{path}, got #{value.class}"
637
+ end
638
+ end
639
+ end