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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +130 -0
- data/lib/raims/api_list.rb +98 -0
- data/lib/raims/api_resource.rb +31 -0
- data/lib/raims/api_response.rb +45 -0
- data/lib/raims/enum_values.rb +208 -0
- data/lib/raims/errors.rb +59 -0
- data/lib/raims/generated/contract.json +6982 -0
- data/lib/raims/http_client.rb +91 -0
- data/lib/raims/models.rb +1863 -0
- data/lib/raims/raims_client.rb +276 -0
- data/lib/raims/request_options.rb +47 -0
- data/lib/raims/schema.rb +104 -0
- data/lib/raims/services.rb +506 -0
- data/lib/raims/util.rb +141 -0
- data/lib/raims/version.rb +5 -0
- data/lib/raims/webhook.rb +72 -0
- data/lib/raims.rb +16 -0
- metadata +143 -0
|
@@ -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
|
data/lib/raims/schema.rb
ADDED
|
@@ -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
|