raims 0.1.1

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.
@@ -0,0 +1,276 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+ require 'ipaddr'
5
+ require 'time'
6
+ require 'thread'
7
+
8
+ module Raims
9
+ class RaimsClient
10
+ MAX_RESPONSE_BYTES = 16 * 1024 * 1024
11
+ RETRY_STATUSES = [408, 429, 500, 502, 503, 504].freeze
12
+ ERROR_CLASSES = {400 => InvalidRequestError, 405 => InvalidRequestError, 413 => InvalidRequestError,
13
+ 415 => InvalidRequestError, 422 => InvalidRequestError, 401 => AuthenticationError,
14
+ 403 => PermissionError, 404 => NotFoundError, 409 => ConflictError, 429 => RateLimitError}.freeze
15
+
16
+ attr_reader :accounts, :balances, :charges, :customers, :disputes, :payment_links,
17
+ :refunds, :statements, :withdrawals, :withdrawal_destinations,
18
+ :webhooks, :webhook_event_types, :simulations
19
+
20
+ def initialize(api_key, api_base: 'https://api.raims.com', timeout: 80, connect_timeout: 10,
21
+ max_network_retries: 0, http_client: nil, request_observer: nil)
22
+ @api_key = Util.visible_ascii(api_key, 4096, 'api_key')
23
+ @api_base = validate_base(api_base)
24
+ @timeout = Util.timeout(timeout)
25
+ @connect_timeout = Util.timeout(connect_timeout)
26
+ @max_network_retries = Util.retries(max_network_retries)
27
+ raise ArgumentError, 'request_observer must respond to call.' if request_observer && !request_observer.respond_to?(:call)
28
+ @observer = request_observer
29
+ @http_client = http_client || NetHttpClient.new
30
+ raise ArgumentError, 'http_client must respond to execute.' unless @http_client.respond_to?(:execute)
31
+ @mutex = Mutex.new
32
+ @condition = ConditionVariable.new
33
+ @closed = false
34
+ @notifying = {}
35
+ Schema::CONTRACT.fetch('services').each_key do |name|
36
+ next if name.start_with?('simulation_')
37
+ klass = name.split('_').map(&:capitalize).join + 'Service'
38
+ instance_variable_set("@#{name}", Raims.const_get(klass, false).new(self))
39
+ end
40
+ @simulations = SimulationsService.new(self)
41
+ end
42
+
43
+ def self.open(api_key, **options)
44
+ client = new(api_key, **options)
45
+ return client unless block_given?
46
+ begin
47
+ yield client
48
+ ensure
49
+ client.close
50
+ end
51
+ end
52
+
53
+ def close
54
+ @mutex.synchronize do
55
+ return if @closed
56
+ @closed = true
57
+ @condition.broadcast
58
+ end
59
+ nil
60
+ end
61
+
62
+ def closed?
63
+ @mutex.synchronize { @closed }
64
+ end
65
+
66
+ def inspect
67
+ "#<#{self.class} version=#{VERSION} closed=#{closed?}>"
68
+ end
69
+ alias to_s inspect
70
+
71
+ def request(operation_id, path_params: {}, params: {}, options: {})
72
+ check_open
73
+ operation = Schema::CONTRACT.fetch('operations') { raise ArgumentError, 'Unknown SDK operation.' }.fetch(operation_id) { raise ArgumentError, 'Unknown SDK operation.' }
74
+ options = RequestOptions.coerce(options)
75
+ if options.idempotency_key && !operation['idempotency']
76
+ raise ArgumentError, 'This operation does not accept an idempotency key.'
77
+ end
78
+ unless path_params.is_a?(Hash) && path_params.keys.sort == operation.fetch('pathParams').sort
79
+ raise ArgumentError, 'The operation received invalid path parameters.'
80
+ end
81
+ path = operation.fetch('path').dup
82
+ path_snapshot = {}
83
+ path_params.each do |name, value|
84
+ value = value.dup if value.is_a?(String)
85
+ unless value.is_a?(String) && value.ascii_only? && value.match?(/\A[A-Za-z0-9_-]+\z/)
86
+ raise ArgumentError, 'Resource identifiers must be non-empty URL-safe strings.'
87
+ end
88
+ path_snapshot[name.dup.freeze] = value.freeze
89
+ path.gsub!("{#{name}}", value)
90
+ end
91
+ path_params = path_snapshot.freeze
92
+ raise ArgumentError, 'params must be a Hash.' unless params.is_a?(Hash)
93
+ snapshot = Util.json_value(params)
94
+ if operation['paginated'] && !snapshot['after'].nil? && !snapshot['before'].nil?
95
+ raise ArgumentError, 'after and before cannot be used together.'
96
+ end
97
+ headers = {'Authorization' => "Bearer #{options.api_key || @api_key}", 'Accept' => 'application/json', 'User-Agent' => "raims-ruby/#{VERSION}"}
98
+ headers['Idempotency-Key'] = options.idempotency_key if options.idempotency_key
99
+ body = nil
100
+ if operation['body']
101
+ body = JSON.generate(snapshot, max_nesting: 100, allow_nan: false)
102
+ if operation['maxBodyBytes'] && body.bytesize > operation['maxBodyBytes']
103
+ raise ArgumentError, 'Request body exceeds the published size limit.'
104
+ end
105
+ headers['Content-Type'] = 'application/json'
106
+ else
107
+ query = []
108
+ snapshot.each do |key, value|
109
+ raise ArgumentError, 'This operation received an unsupported query parameter or body.' unless operation.fetch('query').key?(key)
110
+ next if value.nil?
111
+ (value.is_a?(Array) ? value : [value]).each do |child|
112
+ unless child.is_a?(String) || child.is_a?(Integer) || child.is_a?(Float) || child == true || child == false
113
+ raise ArgumentError, 'Query values must be scalars or lists of scalars.'
114
+ end
115
+ query << [key, child.to_s]
116
+ end
117
+ end
118
+ path += '?' + URI.encode_www_form(query) unless query.empty?
119
+ end
120
+ uri = URI.parse(@api_base + path)
121
+ timeout = options.timeout || @timeout
122
+ retries = %w[GET HEAD].include?(operation['method']) ? (options.max_network_retries || @max_network_retries) : 0
123
+ attempt = 0
124
+ loop do
125
+ check_open
126
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
127
+ metadata = nil
128
+ error = nil
129
+ result = nil
130
+ begin
131
+ raw = @http_client.execute(method: operation['method'], uri: uri.dup,
132
+ headers: headers.dup, body: body&.dup, timeout: timeout,
133
+ connect_timeout: [timeout, @connect_timeout].min,
134
+ max_response_bytes: MAX_RESPONSE_BYTES)
135
+ unless raw.is_a?(HttpResponse) && raw.status.is_a?(Integer) && (100..599).cover?(raw.status) && raw.headers.is_a?(Hash) && raw.body.is_a?(String)
136
+ raise UnexpectedResponseError, 'The HTTP transport returned an invalid response.'
137
+ end
138
+ metadata = ApiResponse.new(raw.status, raw.headers)
139
+ if raw.body.bytesize > MAX_RESPONSE_BYTES
140
+ raise UnexpectedResponseError.new('The API response exceeds the supported size.', last_response: metadata)
141
+ end
142
+ result = interpret(raw.body, metadata, operation, path_params, snapshot, options)
143
+ rescue RaimsError => failure
144
+ error = failure
145
+ metadata ||= failure.last_response
146
+ rescue Zlib::Error
147
+ error = UnexpectedResponseError.new('The API returned invalid compressed content.')
148
+ rescue Net::HTTPHeaderSyntaxError
149
+ error = UnexpectedResponseError.new('The API returned invalid HTTP headers.')
150
+ rescue IOError, SystemCallError, Timeout::Error, SocketError, OpenSSL::SSL::SSLError, Net::HTTPBadResponse, Net::ProtocolError
151
+ error = ConnectionError.new('Could not complete the API request. A write may have been processed; reconcile before retrying.')
152
+ end
153
+ delay = nil
154
+ if error && attempt < retries && (error.is_a?(ConnectionError) || error.is_a?(ApiError) && RETRY_STATUSES.include?(metadata&.status_code))
155
+ delay = retry_delay(attempt, metadata)
156
+ end
157
+ notify(operation, attempt, started, metadata, delay)
158
+ raise error, cause: nil if error && delay.nil?
159
+ return result unless error
160
+ @mutex.synchronize do
161
+ raise ConnectionError, 'The SDK client is closed.' if @closed
162
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + delay
163
+ loop do
164
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
165
+ break if remaining <= 0
166
+ @condition.wait(@mutex, remaining)
167
+ raise ConnectionError, 'The SDK client is closed.' if @closed
168
+ end
169
+ end
170
+ attempt += 1
171
+ end
172
+ end
173
+
174
+ private
175
+
176
+ def check_open
177
+ raise ConnectionError, 'The SDK client is closed.' if closed?
178
+ end
179
+
180
+ def validate_base(value)
181
+ unless value.is_a?(String) && value.ascii_only? && !value.match?(/[\x00-\x20\x7f\\?#]/)
182
+ raise ArgumentError, 'api_base must be an HTTPS origin.'
183
+ end
184
+ uri = URI.parse(value)
185
+ loopback = uri.hostname == 'localhost'
186
+ begin
187
+ loopback ||= IPAddr.new(uri.hostname).loopback?
188
+ rescue IPAddr::InvalidAddressError, ArgumentError
189
+ loopback = false unless loopback
190
+ end
191
+ unless uri.is_a?(URI::HTTP) && uri.hostname && !uri.userinfo && ['', '/'].include?(uri.path) && uri.port.positive? && uri.port <= 65_535 && (uri.scheme == 'https' || uri.scheme == 'http' && loopback)
192
+ raise ArgumentError, 'api_base requires HTTPS without credentials or a path; HTTP is allowed only on loopback.'
193
+ end
194
+ value.delete_suffix('/').freeze
195
+ rescue URI::InvalidURIError
196
+ raise ArgumentError, 'api_base must be an HTTPS origin.', cause: nil
197
+ end
198
+
199
+ def retry_delay(attempt, metadata)
200
+ delay = [2.0, 0.5 * (2**attempt)].min * (0.5 + rand * 0.5)
201
+ header = metadata&.retry_after
202
+ if header
203
+ begin
204
+ wanted = header.strip.match?(/\A\d+\z/) ? header.to_i : Time.httpdate(header).to_f - Time.now.to_f
205
+ return nil if wanted > 30
206
+ delay = [delay, wanted].max
207
+ rescue ArgumentError
208
+ # Invalid server hints do not override the bounded backoff.
209
+ end
210
+ end
211
+ delay
212
+ end
213
+
214
+ def notify(operation, attempt, started, metadata, delay)
215
+ return unless @observer
216
+ context = Fiber.current
217
+ allowed = @mutex.synchronize do
218
+ if @notifying[context]
219
+ false
220
+ else
221
+ @notifying[context] = true
222
+ end
223
+ end
224
+ return unless allowed
225
+ begin
226
+ @observer.call(RequestDiagnostics.new(operation_id: operation['id'], method: operation['method'], path: operation['path'],
227
+ attempt: attempt + 1, duration_ms: (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000,
228
+ status_code: metadata&.status_code, request_id: metadata&.request_id,
229
+ will_retry: !delay.nil?, retry_delay_ms: delay && delay * 1000))
230
+ rescue StandardError
231
+ # Diagnostics must not change the outcome of a financial request.
232
+ ensure
233
+ @mutex.synchronize { @notifying.delete(context) }
234
+ end
235
+ end
236
+
237
+ def interpret(body, metadata, operation, path_params, params, options)
238
+ status = metadata.status_code
239
+ if (300..399).cover?(status)
240
+ raise UnexpectedResponseError.new('The API returned a redirect; redirects are not followed.', last_response: metadata)
241
+ end
242
+ unless (200..299).cover?(status)
243
+ api_error = nil
244
+ begin
245
+ parsed = Util.parse_json(body)
246
+ api_error = parsed['error'] if parsed.is_a?(Hash) && parsed['error'].is_a?(Hash)
247
+ rescue JSON::ParserError, ArgumentError
248
+ # Preserve HTTP failures when an intermediary returns non-JSON content.
249
+ end
250
+ raise ERROR_CLASSES.fetch(status, ApiError).new("The API request failed with HTTP #{status}.", last_response: metadata, api_error: api_error)
251
+ end
252
+ responses = operation.fetch('responses')
253
+ unless responses.key?(status.to_s)
254
+ raise UnexpectedResponseError.new('The API returned an undocumented success status.', last_response: metadata)
255
+ end
256
+ schema = responses[status.to_s]
257
+ if schema.nil?
258
+ raise UnexpectedResponseError.new('The API returned a body for an empty response.', last_response: metadata) unless body.empty?
259
+ return nil
260
+ end
261
+ content_type = metadata.headers['content-type']
262
+ if content_type && !content_type.match?(/\Aapplication\/(?:[a-z0-9.+-]+\+)?json(?:\s*;|\z)/i)
263
+ raise UnexpectedResponseError.new('The API returned a non-JSON success response.', last_response: metadata)
264
+ end
265
+ begin
266
+ data = Util.parse_json(body)
267
+ rescue JSON::ParserError, ArgumentError
268
+ raise UnexpectedResponseError.new('The API returned invalid JSON.', last_response: metadata), cause: nil
269
+ end
270
+ Schema.validate(data, schema, metadata)
271
+ resource = Schema.hydrate(data, schema, metadata)
272
+ return resource unless operation['paginated']
273
+ ApiList.new(resource, client: self, operation_id: operation['id'], path_params: path_params, params: params, options: options)
274
+ end
275
+ end
276
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raims
4
+ class RequestOptions
5
+ attr_reader :api_key, :idempotency_key, :timeout, :max_network_retries
6
+
7
+ def initialize(api_key: nil, idempotency_key: nil, timeout: nil, max_network_retries: nil)
8
+ @api_key = Util.visible_ascii(api_key, 4096, 'api_key') unless api_key.nil?
9
+ @idempotency_key = Util.visible_ascii(idempotency_key, 255, 'idempotency_key') unless idempotency_key.nil?
10
+ @timeout = Util.timeout(timeout) unless timeout.nil?
11
+ @max_network_retries = Util.retries(max_network_retries) unless max_network_retries.nil?
12
+ freeze
13
+ end
14
+
15
+ def self.coerce(value)
16
+ return value if value.is_a?(self)
17
+ raise ArgumentError, 'options must be a Hash or RequestOptions.' unless value.is_a?(Hash)
18
+ mapped = value.each_with_object({}) do |(key, child), result|
19
+ unless %w[api_key idempotency_key timeout max_network_retries].include?(key.to_s) && !result.key?(key.to_s.to_sym)
20
+ raise ArgumentError, 'Request options contain an unsupported or duplicate field.'
21
+ end
22
+ result[key.to_s.to_sym] = child
23
+ end
24
+ new(**mapped)
25
+ end
26
+
27
+ def inspect
28
+ "#<#{self.class}>"
29
+ end
30
+ alias to_s inspect
31
+ end
32
+
33
+ class RequestDiagnostics
34
+ attr_reader :operation_id, :method, :path, :attempt, :duration_ms,
35
+ :status_code, :request_id, :will_retry, :retry_delay_ms
36
+
37
+ def initialize(**fields)
38
+ fields.each { |key, value| instance_variable_set("@#{key}", value.is_a?(String) ? value.dup.freeze : value) }
39
+ freeze
40
+ end
41
+
42
+ def inspect
43
+ "#<#{self.class} operation_id=#{operation_id} attempt=#{attempt} status_code=#{status_code.inspect}>"
44
+ end
45
+ alias to_s inspect
46
+ end
47
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raims
4
+ module Schema
5
+ CONTRACT = Util.deep_freeze(JSON.parse(File.read(File.join(__dir__, 'generated/contract.json'), encoding: 'UTF-8')))
6
+ module_function
7
+
8
+ def resolve(schema, response = nil)
9
+ seen = {}
10
+ while schema.key?('$ref')
11
+ reference = schema.fetch('$ref')
12
+ unless reference.is_a?(String) && reference.start_with?('#/components/schemas/') && !seen[reference] && seen.length < 100
13
+ fail_response(response)
14
+ end
15
+ seen[reference] = true
16
+ key = reference.delete_prefix('#/components/schemas/').gsub('~1', '/').gsub('~0', '~')
17
+ target = CONTRACT.fetch('schemas')[key]
18
+ fail_response(response) unless target.is_a?(Hash)
19
+ schema = target.merge(schema.reject { |name, _value| name == '$ref' })
20
+ end
21
+ schema
22
+ end
23
+
24
+ def validate(value, schema, response = nil, depth = 0)
25
+ fail_response(response) if depth > 100
26
+ schema = resolve(schema, response)
27
+ variants = schema['anyOf'] || schema['oneOf']
28
+ if variants
29
+ matched = variants.any? do |variant|
30
+ begin
31
+ validate(value, variant, response, depth + 1)
32
+ true
33
+ rescue UnexpectedResponseError
34
+ false
35
+ end
36
+ end
37
+ fail_response(response) unless matched
38
+ return
39
+ end
40
+ schema.fetch('allOf', []).each { |part| validate(value, part, response, depth + 1) }
41
+ kinds = Array(schema['type'])
42
+ if value.nil?
43
+ return if kinds.empty? || kinds.include?('null') || schema['nullable']
44
+ fail_response(response)
45
+ end
46
+ kind = kinds.find { |candidate| candidate != 'null' }
47
+ case kind
48
+ when 'object', nil
49
+ if kind == 'object' || schema.key?('properties')
50
+ fail_response(response) unless value.is_a?(Hash) && schema.fetch('required', []).all? { |key| value.key?(key) }
51
+ additional = schema['additionalProperties']
52
+ value.each do |key, child|
53
+ child_schema = schema.fetch('properties', {})[key] || (additional.is_a?(Hash) ? additional : {})
54
+ validate(child, child_schema, response, depth + 1)
55
+ end
56
+ end
57
+ when 'array'
58
+ fail_response(response) unless value.is_a?(Array)
59
+ value.each { |child| validate(child, schema.fetch('items', {}), response, depth + 1) }
60
+ when 'integer' then fail_response(response) unless value.is_a?(Integer)
61
+ when 'number' then fail_response(response) unless value.is_a?(Numeric) && value.finite?
62
+ when 'boolean' then fail_response(response) unless value == true || value == false
63
+ when 'string' then fail_response(response) unless value.is_a?(String)
64
+ when 'null' then fail_response(response)
65
+ end
66
+ end
67
+
68
+ def fail_response(response)
69
+ raise UnexpectedResponseError.new('The API response does not match the published structure.', last_response: response)
70
+ end
71
+
72
+ def hydrate(value, schema = {}, response = nil, depth = 0)
73
+ fail_response(response) if depth > 100
74
+ model = schema.fetch('$ref', '').split('/').last
75
+ resolved = resolve(schema, response)
76
+ variants = resolved['anyOf'] || resolved['oneOf']
77
+ if variants
78
+ variants.each do |variant|
79
+ begin
80
+ validate(value, variant, response, depth + 1)
81
+ return hydrate(value, variant, response, depth + 1)
82
+ rescue UnexpectedResponseError
83
+ next
84
+ end
85
+ end
86
+ end
87
+ case value
88
+ when Hash
89
+ props = resolved.fetch('properties', {})
90
+ additional = resolved['additionalProperties']
91
+ data = value.transform_keys(&:dup).each_with_object({}) do |(key, child), result|
92
+ result[key] = hydrate(child, props[key] || (additional.is_a?(Hash) ? additional : {}), nil, depth + 1)
93
+ end
94
+ klass = model && Raims.const_defined?(model, false) ? Raims.const_get(model, false) : ApiResource
95
+ klass = ApiResource unless klass.is_a?(Class) && klass <= ApiResource
96
+ klass.new(data, last_response: response)
97
+ when Array
98
+ value.map { |child| hydrate(child, resolved.fetch('items', {}), nil, depth + 1) }.freeze
99
+ when String then value.freeze
100
+ else value
101
+ end
102
+ end
103
+ end
104
+ end