erpc-sdk 0.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.
@@ -0,0 +1,370 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module ERPC
7
+ module Validation
8
+ module_function
9
+
10
+ def mapping(value)
11
+ raise InvalidResponseError unless value.is_a?(Hash) && value.keys.all? { |key| key.is_a?(String) }
12
+
13
+ value
14
+ end
15
+
16
+ def string(value)
17
+ raise InvalidResponseError unless value.is_a?(String)
18
+
19
+ value
20
+ end
21
+
22
+ def number(value, integer: false, nonnegative: false)
23
+ valid = value.is_a?(Numeric) && value.finite?
24
+ valid &&= value.is_a?(Integer) if integer
25
+ valid &&= value >= 0 if nonnegative
26
+ raise InvalidResponseError unless valid
27
+
28
+ value
29
+ end
30
+
31
+ def datetime(value)
32
+ text = string(value)
33
+ Time.iso8601(text)
34
+ text
35
+ rescue ArgumentError
36
+ raise InvalidResponseError
37
+ end
38
+ end
39
+
40
+ class AccountClient
41
+ def initialize(transport)
42
+ @transport = transport
43
+ end
44
+
45
+ def get_token_balance
46
+ data = Validation.mapping(@transport.get("/v3/erpc/token-balance"))
47
+ unless %w[business developer free pro].include?(data["plan"])
48
+ raise InvalidResponseError, "ERPC returned an invalid token balance"
49
+ end
50
+ Validation.number(data["max_tokens"], integer: true)
51
+ Validation.number(data["remaining_tokens"], integer: true)
52
+ Validation.string(data["next_refill_at"]) unless data["next_refill_at"].nil?
53
+ data
54
+ rescue InvalidResponseError
55
+ raise InvalidResponseError, "ERPC returned an invalid token balance"
56
+ end
57
+ end
58
+
59
+ class UsageClient
60
+ YEAR_MONTH = /\A\d{4}-(0[1-9]|1[0-2])\z/
61
+ private_constant :YEAR_MONTH
62
+
63
+ def initialize(transport)
64
+ @transport = transport
65
+ end
66
+
67
+ def get_monthly_api_key_usage(year_month = nil)
68
+ if !year_month.nil? && !YEAR_MONTH.match?(year_month)
69
+ raise ConfigError, "year_month must use YYYY-MM format"
70
+ end
71
+
72
+ envelope = Validation.mapping(@transport.get("/v3/user/api-keys/usage", "yearMonth" => year_month))
73
+ raise InvalidResponseError unless envelope["success"] == true
74
+
75
+ usage = Validation.mapping(envelope["message"])
76
+ validate_usage(usage)
77
+ usage
78
+ rescue InvalidResponseError
79
+ raise InvalidResponseError, "ERPC returned an invalid monthly API key usage response"
80
+ end
81
+
82
+ private
83
+
84
+ def validate_usage(usage)
85
+ raise InvalidResponseError unless YEAR_MONTH.match?(Validation.string(usage["yearMonth"]))
86
+
87
+ %w[keyCount totalCount totalCredits].each { |field| Validation.number(usage[field]) }
88
+ raise InvalidResponseError unless [true, false].include?(usage["hasStrandedUsage"])
89
+ Validation.string(usage["updatedAt"]) unless usage["updatedAt"].nil?
90
+ validate_chains(usage["chains"])
91
+ raise InvalidResponseError unless usage["apiKeys"].is_a?(Array)
92
+
93
+ usage["apiKeys"].each do |raw|
94
+ item = Validation.mapping(raw)
95
+ Validation.string(item["apiKeyLast4"])
96
+ %w[apiKeyLength count credits].each { |field| Validation.number(item[field]) }
97
+ Validation.number(item["keyId"]) unless item["keyId"].nil?
98
+ Validation.string(item["updatedAt"]) unless item["updatedAt"].nil?
99
+ validate_chains(item["chains"])
100
+ end
101
+ end
102
+
103
+ def validate_chains(value)
104
+ raise InvalidResponseError unless value.is_a?(Array)
105
+
106
+ value.each do |raw_chain|
107
+ chain = Validation.mapping(raw_chain)
108
+ Validation.string(chain["chain"])
109
+ %w[count credits].each { |field| Validation.number(chain[field]) }
110
+ Validation.string(chain["updatedAt"]) unless chain["updatedAt"].nil?
111
+ raise InvalidResponseError unless chain["methods"].is_a?(Array)
112
+
113
+ chain["methods"].each do |raw_method|
114
+ method = Validation.mapping(raw_method)
115
+ Validation.string(method["method"])
116
+ %w[count creditCost credits].each { |field| Validation.number(method[field]) }
117
+ Validation.string(method["updatedAt"]) unless method["updatedAt"].nil?
118
+ end
119
+ end
120
+ end
121
+ end
122
+
123
+ class PriceClient
124
+ def initialize(transport)
125
+ @transport = transport
126
+ end
127
+
128
+ def get_price_feeds(asset_type: nil, query: nil)
129
+ value = @transport.get("/v2/price_feeds", "query" => query, "asset_type" => asset_type)
130
+ raise InvalidResponseError, "ERPC returned invalid price feed metadata" unless value.is_a?(Array)
131
+
132
+ value.each do |raw|
133
+ metadata = Validation.mapping(raw)
134
+ Validation.string(metadata["id"])
135
+ attributes = metadata["attributes"]
136
+ next if attributes.nil?
137
+ unless attributes.is_a?(Hash) && attributes.all? { |key, item| key.is_a?(String) && item.is_a?(String) }
138
+ raise InvalidResponseError, "ERPC returned invalid price feed metadata"
139
+ end
140
+ end
141
+ value
142
+ rescue InvalidResponseError
143
+ raise InvalidResponseError, "ERPC returned invalid price feed metadata"
144
+ end
145
+
146
+ def get_latest_price_updates(ids:, encoding: nil, parsed: nil, ignore_invalid_price_ids: nil)
147
+ value = @transport.get(
148
+ "/v2/updates/price/latest",
149
+ update_query(ids, encoding, parsed, ignore_invalid_price_ids)
150
+ )
151
+ price_update(value)
152
+ end
153
+
154
+ def get_price_updates_at_timestamp(publish_time, ids:, encoding: nil, parsed: nil,
155
+ ignore_invalid_price_ids: nil)
156
+ value = @transport.get(
157
+ "/v2/updates/price/#{URLs.escape_path(publish_time)}",
158
+ update_query(ids, encoding, parsed, ignore_invalid_price_ids)
159
+ )
160
+ price_update(value)
161
+ end
162
+
163
+ def get_latest_publisher_stake_caps(encoding: nil, parsed: nil)
164
+ Validation.mapping(
165
+ @transport.get(
166
+ "/v2/updates/publisher_stake_caps/latest",
167
+ "encoding" => encoding,
168
+ "parsed" => parsed
169
+ )
170
+ )
171
+ end
172
+
173
+ def stream_price_updates(ids:, encoding: nil, parsed: nil, ignore_invalid_price_ids: nil,
174
+ allow_unordered: nil, benchmarks_only: nil)
175
+ query = update_query(ids, encoding, parsed, ignore_invalid_price_ids).merge(
176
+ "allow_unordered" => allow_unordered,
177
+ "benchmarks_only" => benchmarks_only
178
+ )
179
+ chunks = @transport.stream("/v2/updates/price/stream", query)
180
+ Enumerator.new do |yielder|
181
+ buffer = +""
182
+ chunks.each do |chunk|
183
+ buffer << chunk
184
+ while (boundary = buffer.match(/\r?\n\r?\n/))
185
+ block = buffer.slice!(0, boundary.end(0))
186
+ event = parse_sse_block(block.sub(/\r?\n\r?\n\z/, ""))
187
+ yielder << event if event
188
+ end
189
+ end
190
+ event = parse_sse_block(buffer)
191
+ yielder << event if event
192
+ end
193
+ end
194
+
195
+ private
196
+
197
+ def update_query(ids, encoding, parsed, ignore_invalid_price_ids)
198
+ {
199
+ "ids[]" => ids,
200
+ "encoding" => encoding,
201
+ "parsed" => parsed,
202
+ "ignore_invalid_price_ids" => ignore_invalid_price_ids
203
+ }
204
+ end
205
+
206
+ def price_update(value)
207
+ update = Validation.mapping(value)
208
+ binary = Validation.mapping(update["binary"])
209
+ data = binary["data"]
210
+ valid = data.is_a?(Array) && data.all? { |item| item.is_a?(String) }
211
+ valid &&= binary["encoding"].is_a?(String)
212
+ valid &&= update["parsed"].is_a?(Array) if update.key?("parsed")
213
+ raise InvalidResponseError, "ERPC returned an invalid price update" unless valid
214
+
215
+ update
216
+ rescue InvalidResponseError
217
+ raise InvalidResponseError, "ERPC returned an invalid price update"
218
+ end
219
+
220
+ def parse_sse_block(block)
221
+ data = []
222
+ result = {}
223
+ block.each_line(chomp: true) do |line|
224
+ next if line.empty? || line.start_with?(":")
225
+
226
+ field, separator, raw = line.partition(":")
227
+ value = separator.empty? ? "" : raw.sub(/\A /, "")
228
+ data << value if field == "data"
229
+ result[field] = value if %w[event id].include?(field)
230
+ end
231
+ return nil if data.empty?
232
+
233
+ result["data"] = price_update(JSON.parse(data.join("\n")))
234
+ result
235
+ rescue JSON::ParserError, InvalidResponseError
236
+ raise InvalidResponseError, "ERPC returned malformed stream data"
237
+ end
238
+ end
239
+
240
+ class CloudCatalogClient
241
+ RESOURCE_KINDS = %w[bare-metal solana-grpc solana-shredstream vps].freeze
242
+ RESOURCE_MODES = %w[dedicated direct shared].freeze
243
+
244
+ def initialize(transport)
245
+ @transport = transport
246
+ end
247
+
248
+ def list
249
+ envelope = Validation.mapping(@transport.get("/v4/cloud/catalog"))
250
+ message = Validation.mapping(envelope["message"])
251
+ offerings = message["offerings"]
252
+ raise InvalidResponseError unless envelope["success"] == true && offerings.is_a?(Array)
253
+
254
+ offerings.each do |raw|
255
+ item = Validation.mapping(raw)
256
+ %w[id name description].each { |field| Validation.string(item[field]) }
257
+ raise InvalidResponseError unless RESOURCE_KINDS.include?(item["kind"])
258
+ raise InvalidResponseError if item["mode"] && !RESOURCE_MODES.include?(item["mode"])
259
+ %w[regions capabilities].each do |field|
260
+ array = item[field]
261
+ raise InvalidResponseError unless array.is_a?(Array) && array.all? { |entry| entry.is_a?(String) }
262
+ end
263
+ end
264
+ offerings
265
+ rescue InvalidResponseError
266
+ raise InvalidResponseError, "ERPC returned an invalid Cloud catalog"
267
+ end
268
+ end
269
+
270
+ class CloudCreditClient
271
+ ALERT_LEVELS = %w[critical normal suspended warning].freeze
272
+
273
+ def initialize(transport)
274
+ @transport = transport
275
+ end
276
+
277
+ def get
278
+ envelope = Validation.mapping(@transport.get("/v4/cloud/credit"))
279
+ raise InvalidResponseError unless envelope["success"] == true
280
+
281
+ credit = Validation.mapping(envelope["message"])
282
+ raise InvalidResponseError unless ALERT_LEVELS.include?(credit["alertLevel"])
283
+ Validation.number(credit["balanceCents"], integer: true)
284
+ Validation.number(credit["burnRateCentsPerHour"], integer: true, nonnegative: true)
285
+ Validation.number(credit["timeToZeroHours"], nonnegative: true) unless credit["timeToZeroHours"].nil?
286
+ Validation.datetime(credit["quoteTimestamp"])
287
+ Validation.datetime(credit["quoteExpiresAt"])
288
+ credit
289
+ rescue InvalidResponseError
290
+ raise InvalidResponseError, "ERPC returned an invalid Cloud credit snapshot"
291
+ end
292
+ end
293
+
294
+ class CloudResourcesClient
295
+ RESOURCE_KINDS = CloudCatalogClient::RESOURCE_KINDS
296
+ RESOURCE_MODES = CloudCatalogClient::RESOURCE_MODES
297
+ BILLING_STATUSES = %w[active grace-period inactive suspended].freeze
298
+
299
+ def initialize(transport)
300
+ @transport = transport
301
+ end
302
+
303
+ def list
304
+ envelope = Validation.mapping(@transport.get("/v4/cloud/resources"))
305
+ message = Validation.mapping(envelope["message"])
306
+ resources = message["resources"]
307
+ raise InvalidResponseError unless envelope["success"] == true && resources.is_a?(Array)
308
+
309
+ resources.each { |resource| validate_resource(resource) }
310
+ resources
311
+ rescue InvalidResponseError
312
+ raise InvalidResponseError, "ERPC returned an invalid Cloud resource list"
313
+ end
314
+
315
+ def get(resource_id)
316
+ value = @transport.get("/v4/cloud/resources/#{URLs.escape_path(normalize_id(resource_id))}")
317
+ envelope = Validation.mapping(value)
318
+ message = Validation.mapping(envelope["message"])
319
+ raise InvalidResponseError unless envelope["success"] == true
320
+
321
+ validate_resource(message["resource"])
322
+ rescue InvalidResponseError
323
+ raise InvalidResponseError, "ERPC returned an invalid Cloud resource"
324
+ end
325
+
326
+ def get_status(resource_id)
327
+ path = "/v4/cloud/resources/#{URLs.escape_path(normalize_id(resource_id))}/status"
328
+ envelope = Validation.mapping(@transport.get(path))
329
+ raise InvalidResponseError unless envelope["success"] == true
330
+
331
+ status = Validation.mapping(envelope["message"])
332
+ Validation.string(status["id"])
333
+ Validation.string(status["status"])
334
+ validate_billing(status["billing"]) if status.key?("billing")
335
+ status
336
+ rescue InvalidResponseError
337
+ raise InvalidResponseError, "ERPC returned an invalid Cloud resource status"
338
+ end
339
+
340
+ private
341
+
342
+ def normalize_id(value)
343
+ result = value.to_s.strip
344
+ raise ConfigError, "resource_id must not be empty" if result.empty?
345
+
346
+ result
347
+ end
348
+
349
+ def validate_resource(value)
350
+ resource = Validation.mapping(value)
351
+ Validation.string(resource["id"])
352
+ Validation.string(resource["status"])
353
+ raise InvalidResponseError unless RESOURCE_KINDS.include?(resource["kind"])
354
+ raise InvalidResponseError if resource["mode"] && !RESOURCE_MODES.include?(resource["mode"])
355
+ %w[name region createdAt].each do |field|
356
+ Validation.string(resource[field]) unless resource[field].nil?
357
+ end
358
+ resource
359
+ end
360
+
361
+ def validate_billing(value)
362
+ billing = Validation.mapping(value)
363
+ raise InvalidResponseError unless BILLING_STATUSES.include?(billing["status"])
364
+ Validation.number(billing["hourlyCredits"], nonnegative: true) if billing.key?("hourlyCredits")
365
+ %w[nextChargeAt graceEndsAt].each do |field|
366
+ Validation.datetime(billing[field]) if billing.key?(field)
367
+ end
368
+ end
369
+ end
370
+ end
@@ -0,0 +1,269 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ERPC
4
+ SOLANA_RPC_METHODS = [
5
+ "getAccountInfo",
6
+ "getBalance",
7
+ "getBlock",
8
+ "getBlockCommitment",
9
+ "getBlockHeight",
10
+ "getBlockProduction",
11
+ "getBlockTime",
12
+ "getBlocks",
13
+ "getBlocksWithLimit",
14
+ "getClusterNodes",
15
+ "getEpochInfo",
16
+ "getEpochSchedule",
17
+ "getFeeForMessage",
18
+ "getFirstAvailableBlock",
19
+ "getGenesisHash",
20
+ "getHealth",
21
+ "getHighestSnapshotSlot",
22
+ "getIdentity",
23
+ "getInflationGovernor",
24
+ "getInflationRate",
25
+ "getInflationReward",
26
+ "getLargestAccounts",
27
+ "getLatestBlockhash",
28
+ "getLeaderSchedule",
29
+ "getMaxRetransmitSlot",
30
+ "getMaxShredInsertSlot",
31
+ "getMinimumBalanceForRentExemption",
32
+ "getMultipleAccounts",
33
+ "getParsedTransaction",
34
+ "getPriorityFeeEstimate",
35
+ "getProgramAccounts",
36
+ "getProgramAccountsV2",
37
+ "getRecentPerformanceSamples",
38
+ "getRecentPrioritizationFees",
39
+ "getSignatureStatuses",
40
+ "getSignaturesForAddress",
41
+ "getSlot",
42
+ "getSlotLeader",
43
+ "getSlotLeaders",
44
+ "getStakeMinimumDelegation",
45
+ "getSupply",
46
+ "getTokenAccountBalance",
47
+ "getTokenAccountsByDelegate",
48
+ "getTokenAccountsByOwner",
49
+ "getTokenLargestAccounts",
50
+ "getTokenSupply",
51
+ "getTransaction",
52
+ "getTransactionCount",
53
+ "getVersion",
54
+ "getVoteAccounts",
55
+ "isBlockhashValid",
56
+ "minimumLedgerSlot",
57
+ "requestAirdrop",
58
+ "sendTransaction",
59
+ "simulateTransaction"
60
+ ].freeze
61
+
62
+ SOLANA_DAS_METHODS = [
63
+ "getAsset",
64
+ "getAssetBatch",
65
+ "getAssetProof",
66
+ "getAssetProofBatch",
67
+ "getAssetsByAuthority",
68
+ "getAssetsByCreator",
69
+ "getAssetsByGroup",
70
+ "getAssetsByOwner",
71
+ "getNftEditions",
72
+ "getSignaturesForAsset",
73
+ "getTokenAccounts",
74
+ "getTokensByDelegate",
75
+ "getTokensByOwner",
76
+ "searchAssets"
77
+ ].freeze
78
+
79
+ SOLANA_HISTORY_METHODS = [
80
+ "getTransactionsForAddress",
81
+ "getTransfersByAddress"
82
+ ].freeze
83
+
84
+ SOLANA_LEADER_METHODS = [
85
+ "getLeaderSlots",
86
+ "getValidatorsInformation"
87
+ ].freeze
88
+
89
+ SOLANA_ANALYTICS_METHODS = [
90
+ "jetEpochSummary",
91
+ "jetProgramStats",
92
+ "jetSlotStats",
93
+ "jetTopPrograms",
94
+ "jetTpsTimeseries"
95
+ ].freeze
96
+
97
+ SOLANA_ENHANCED_SUBSCRIPTION_METHODS = [
98
+ "accountSubscribe",
99
+ "accountUnsubscribe",
100
+ "transactionSubscribe",
101
+ "transactionUnsubscribe"
102
+ ].freeze
103
+
104
+ ETHEREUM_RPC_METHODS = [
105
+ "eth_accounts",
106
+ "eth_baseFee",
107
+ "eth_blobBaseFee",
108
+ "eth_blockNumber",
109
+ "eth_call",
110
+ "eth_callMany",
111
+ "eth_capabilities",
112
+ "eth_chainId",
113
+ "eth_createAccessList",
114
+ "eth_estimateGas",
115
+ "eth_feeHistory",
116
+ "eth_gasPrice",
117
+ "eth_getAccount",
118
+ "eth_getBalance",
119
+ "eth_getBlockByHash",
120
+ "eth_getBlockByNumber",
121
+ "eth_getBlockReceipts",
122
+ "eth_getBlockTransactionCountByHash",
123
+ "eth_getBlockTransactionCountByNumber",
124
+ "eth_getCode",
125
+ "eth_getFilterChanges",
126
+ "eth_getFilterLogs",
127
+ "eth_getLogs",
128
+ "eth_getProof",
129
+ "eth_getRawTransactionByHash",
130
+ "eth_getStorageAt",
131
+ "eth_getTransactionByBlockHashAndIndex",
132
+ "eth_getTransactionByBlockNumberAndIndex",
133
+ "eth_getTransactionByHash",
134
+ "eth_getTransactionBySenderAndNonce",
135
+ "eth_getTransactionCount",
136
+ "eth_getTransactionReceipt",
137
+ "eth_getUncleCountByBlockHash",
138
+ "eth_getUncleCountByBlockNumber",
139
+ "eth_maxPriorityFeePerGas",
140
+ "eth_newBlockFilter",
141
+ "eth_newFilter",
142
+ "eth_newPendingTransactionFilter",
143
+ "eth_sendRawTransaction",
144
+ "eth_signTransaction",
145
+ "eth_simulateV1",
146
+ "eth_submitWork",
147
+ "eth_syncing",
148
+ "eth_uninstallFilter",
149
+ "net_listening",
150
+ "net_peerCount",
151
+ "net_version",
152
+ "txpool_content",
153
+ "txpool_contentFrom",
154
+ "txpool_inspect",
155
+ "txpool_status",
156
+ "web3_clientVersion",
157
+ "web3_sha3"
158
+ ].freeze
159
+
160
+ ETHEREUM_SUBSCRIPTION_METHODS = [
161
+ "eth_subscribe",
162
+ "eth_unsubscribe"
163
+ ].freeze
164
+
165
+ HEAVY_SOLANA_METHODS = %w[
166
+ getPriorityFeeEstimate
167
+ getProgramAccounts
168
+ getProgramAccountsV2
169
+ getTokenLargestAccounts
170
+ ].freeze
171
+ private_constant :HEAVY_SOLANA_METHODS
172
+
173
+ class PendingRpcRequest
174
+ def initialize(transport, method, params, decoder)
175
+ @transport = transport
176
+ @method = method
177
+ @params = params
178
+ @decoder = decoder
179
+ end
180
+
181
+ def send
182
+ @decoder.call(@transport.request(@method, @params))
183
+ end
184
+ end
185
+
186
+ class PendingRpcBatchRequest
187
+ def initialize(transport, calls)
188
+ @transport = transport
189
+ @calls = calls.map(&:dup).freeze
190
+ end
191
+
192
+ def send
193
+ @transport.batch(@calls)
194
+ end
195
+ end
196
+
197
+ class RpcNamespace
198
+ attr_reader :endpoint
199
+
200
+ def initialize(transport, methods, parameter_mode:, batch_policy: :any)
201
+ @transport = transport
202
+ @endpoint = transport.endpoint
203
+ @methods = methods.to_h { |method| [method, true] }.freeze
204
+ @aliases = methods.to_h { |method| [snake_case(method), method] }.freeze
205
+ @parameter_mode = parameter_mode
206
+ @batch_policy = batch_policy
207
+ end
208
+
209
+ def request(method, params = nil, decoder: nil)
210
+ unless @methods.key?(method)
211
+ raise ConfigError, "#{method.inspect} is not in this namespace; use raw for forward-compatible methods"
212
+ end
213
+
214
+ PendingRpcRequest.new(@transport, method, params, decoder || ->(value) { value })
215
+ end
216
+
217
+ def raw(method, params = nil, decoder: nil)
218
+ raise ConfigError, "method must not be empty" if method.to_s.empty?
219
+
220
+ PendingRpcRequest.new(@transport, method, params, decoder || ->(value) { value })
221
+ end
222
+
223
+ def batch(calls)
224
+ if @batch_policy == :unsupported && !calls.empty?
225
+ raise BatchPolicyError, "Leader RPC methods do not support batching"
226
+ end
227
+ if @batch_policy == :solana_standard
228
+ methods = calls.map { |call| call[:method] || call["method"] }
229
+ has_heavy = methods.any? { |method| HEAVY_SOLANA_METHODS.include?(method) }
230
+ has_standard = methods.any? { |method| !HEAVY_SOLANA_METHODS.include?(method) }
231
+ if has_heavy && has_standard
232
+ raise BatchPolicyError, "Solana indexed and standard RPC methods cannot share a batch"
233
+ end
234
+ end
235
+
236
+ PendingRpcBatchRequest.new(@transport, calls)
237
+ end
238
+
239
+ def method_missing(name, *arguments, &block)
240
+ return super if block
241
+
242
+ string_name = name.to_s
243
+ method = @methods.key?(string_name) ? string_name : @aliases[string_name]
244
+ return super unless method
245
+
246
+ params = if @parameter_mode == :positional
247
+ arguments
248
+ elsif arguments.empty?
249
+ nil
250
+ elsif arguments.length == 1 && arguments.first.is_a?(Hash)
251
+ arguments.first
252
+ else
253
+ raise ConfigError, "named RPC methods require one Hash argument"
254
+ end
255
+ request(method, params)
256
+ end
257
+
258
+ def respond_to_missing?(name, include_private = false)
259
+ string_name = name.to_s
260
+ @methods.key?(string_name) || @aliases.key?(string_name) || super
261
+ end
262
+
263
+ private
264
+
265
+ def snake_case(value)
266
+ value.gsub(/(?<=[a-z0-9])(?=[A-Z])/, "_").downcase
267
+ end
268
+ end
269
+ end