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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +101 -0
- data/lib/erpc_sdk/client.rb +136 -0
- data/lib/erpc_sdk/config.rb +99 -0
- data/lib/erpc_sdk/errors.rb +74 -0
- data/lib/erpc_sdk/rest.rb +370 -0
- data/lib/erpc_sdk/rpc.rb +269 -0
- data/lib/erpc_sdk/transport.rb +225 -0
- data/lib/erpc_sdk/version.rb +5 -0
- data/lib/erpc_sdk/websocket.rb +458 -0
- data/lib/erpc_sdk.rb +10 -0
- metadata +100 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "socket"
|
|
7
|
+
require "thread"
|
|
8
|
+
require "uri"
|
|
9
|
+
|
|
10
|
+
module ERPC
|
|
11
|
+
HttpResponse = Struct.new(:status, :body, keyword_init: true)
|
|
12
|
+
|
|
13
|
+
class NetHttpAdapter
|
|
14
|
+
def request(method:, url:, headers:, body: nil, timeout: DEFAULT_TIMEOUT)
|
|
15
|
+
uri = URI.parse(url)
|
|
16
|
+
request = request_class(method).new(uri.request_uri, headers)
|
|
17
|
+
request.body = body unless body.nil?
|
|
18
|
+
response = start(uri, timeout) { |http| http.request(request) }
|
|
19
|
+
HttpResponse.new(status: response.code.to_i, body: response.body.to_s)
|
|
20
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout
|
|
21
|
+
raise TimeoutError, timeout
|
|
22
|
+
rescue IOError, EOFError, SocketError, SystemCallError, OpenSSL::SSL::SSLError
|
|
23
|
+
raise TransportError, "Unable to reach ERPC"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def stream(url:, headers:, timeout: DEFAULT_TIMEOUT)
|
|
27
|
+
Enumerator.new do |yielder|
|
|
28
|
+
uri = URI.parse(url)
|
|
29
|
+
request = Net::HTTP::Get.new(uri.request_uri, headers)
|
|
30
|
+
start(uri, timeout) do |http|
|
|
31
|
+
http.request(request) do |response|
|
|
32
|
+
status = response.code.to_i
|
|
33
|
+
raise HttpError, status unless status.between?(200, 299)
|
|
34
|
+
|
|
35
|
+
response.read_body { |chunk| yielder << chunk }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout
|
|
39
|
+
raise TimeoutError, timeout
|
|
40
|
+
rescue HttpError
|
|
41
|
+
raise
|
|
42
|
+
rescue IOError, EOFError, SocketError, SystemCallError, OpenSSL::SSL::SSLError
|
|
43
|
+
raise TransportError, "Unable to reach ERPC"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def start(uri, timeout, &block)
|
|
50
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: timeout,
|
|
51
|
+
read_timeout: timeout, write_timeout: timeout, &block)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def request_class(method)
|
|
55
|
+
{ get: Net::HTTP::Get, post: Net::HTTP::Post }.fetch(method.to_sym)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
class HttpJsonRpcTransport
|
|
60
|
+
attr_reader :endpoint, :max_batch_size
|
|
61
|
+
|
|
62
|
+
def initialize(api_key:, endpoint:, headers:, timeout:, adapter:, max_batch_size: 256)
|
|
63
|
+
@api_key = api_key
|
|
64
|
+
@endpoint = endpoint
|
|
65
|
+
@headers = headers
|
|
66
|
+
@timeout = timeout
|
|
67
|
+
@adapter = adapter
|
|
68
|
+
@max_batch_size = max_batch_size
|
|
69
|
+
@next_id = 0
|
|
70
|
+
@id_mutex = Mutex.new
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def request(method, params = nil)
|
|
74
|
+
request_id = next_id
|
|
75
|
+
body = { "jsonrpc" => "2.0", "id" => request_id, "method" => method }
|
|
76
|
+
body["params"] = params unless params.nil?
|
|
77
|
+
unwrap(post(body), request_id)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def batch(calls)
|
|
81
|
+
return [] if calls.empty?
|
|
82
|
+
if calls.length > max_batch_size
|
|
83
|
+
raise InvalidResponseError, "A batch may contain at most #{max_batch_size} calls"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
requests = calls.map do |call|
|
|
87
|
+
method = call.fetch(:method) { call.fetch("method") }
|
|
88
|
+
item = { "jsonrpc" => "2.0", "id" => next_id, "method" => method }
|
|
89
|
+
if call.key?(:params) || call.key?("params")
|
|
90
|
+
item["params"] = call.key?(:params) ? call[:params] : call["params"]
|
|
91
|
+
end
|
|
92
|
+
item
|
|
93
|
+
rescue KeyError
|
|
94
|
+
raise ConfigError, "batch calls require a method"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
response = post(requests)
|
|
98
|
+
unless response.is_a?(Array)
|
|
99
|
+
raise_rpc(response["error"]) if response.is_a?(Hash) && response["error"].is_a?(Hash)
|
|
100
|
+
raise InvalidResponseError, "ERPC returned a non-array batch response"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
by_id = {}
|
|
104
|
+
response.each do |item|
|
|
105
|
+
unless item.is_a?(Hash) && valid_id?(item["id"])
|
|
106
|
+
raise InvalidResponseError, "ERPC returned an invalid batch item"
|
|
107
|
+
end
|
|
108
|
+
raise InvalidResponseError, "ERPC returned a duplicate batch id" if by_id.key?(item["id"])
|
|
109
|
+
|
|
110
|
+
by_id[item["id"]] = item
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
results = requests.map do |request|
|
|
114
|
+
item = by_id.delete(request["id"])
|
|
115
|
+
raise InvalidResponseError, "ERPC omitted a batch response" unless item
|
|
116
|
+
|
|
117
|
+
unwrap(item, request["id"])
|
|
118
|
+
end
|
|
119
|
+
raise InvalidResponseError, "ERPC returned an unexpected batch response id" unless by_id.empty?
|
|
120
|
+
|
|
121
|
+
results
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
def next_id
|
|
127
|
+
@id_mutex.synchronize { @next_id += 1 }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def post(body)
|
|
131
|
+
uri = URI.parse(endpoint)
|
|
132
|
+
query = URI.decode_www_form(uri.query.to_s)
|
|
133
|
+
query << ["api-key", @api_key]
|
|
134
|
+
uri.query = URI.encode_www_form(query)
|
|
135
|
+
response = @adapter.request(
|
|
136
|
+
method: :post,
|
|
137
|
+
url: uri.to_s,
|
|
138
|
+
headers: @headers.merge("accept" => "application/json", "content-type" => "application/json"),
|
|
139
|
+
body: JSON.generate(body),
|
|
140
|
+
timeout: @timeout
|
|
141
|
+
)
|
|
142
|
+
raise HttpError, response.status unless response.status.between?(200, 299)
|
|
143
|
+
|
|
144
|
+
JSON.parse(response.body)
|
|
145
|
+
rescue JSON::ParserError
|
|
146
|
+
raise InvalidResponseError, "ERPC returned malformed JSON"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def unwrap(response, expected_id)
|
|
150
|
+
unless response.is_a?(Hash) && response["id"] == expected_id
|
|
151
|
+
raise InvalidResponseError, "ERPC returned an unexpected response id"
|
|
152
|
+
end
|
|
153
|
+
raise_rpc(response["error"]) if response.key?("error")
|
|
154
|
+
raise InvalidResponseError, "ERPC returned an invalid response" unless response.key?("result")
|
|
155
|
+
|
|
156
|
+
response["result"]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def raise_rpc(error)
|
|
160
|
+
unless error.is_a?(Hash) && error["code"].is_a?(Integer) && error["message"].is_a?(String)
|
|
161
|
+
raise InvalidResponseError, "ERPC returned an invalid RPC error"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
data = error.key?("data") ? Redaction.value(error["data"], @api_key) : nil
|
|
165
|
+
raise JsonRpcError.new(error["code"], Redaction.text(error["message"], @api_key), data)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def valid_id?(value)
|
|
169
|
+
value.is_a?(Integer) || value.is_a?(String)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
class RestTransport
|
|
174
|
+
attr_reader :endpoint
|
|
175
|
+
|
|
176
|
+
def initialize(credential:, endpoint:, headers:, timeout:, adapter:)
|
|
177
|
+
@credential = credential
|
|
178
|
+
@endpoint = endpoint
|
|
179
|
+
@headers = headers
|
|
180
|
+
@timeout = timeout
|
|
181
|
+
@adapter = adapter
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def get(path, query = nil)
|
|
185
|
+
response = @adapter.request(
|
|
186
|
+
method: :get,
|
|
187
|
+
url: url(path, query),
|
|
188
|
+
headers: @headers.merge("authorization" => "Bearer #{@credential}", "accept" => "application/json"),
|
|
189
|
+
timeout: @timeout
|
|
190
|
+
)
|
|
191
|
+
raise HttpError, response.status unless response.status.between?(200, 299)
|
|
192
|
+
|
|
193
|
+
JSON.parse(response.body)
|
|
194
|
+
rescue JSON::ParserError
|
|
195
|
+
raise InvalidResponseError, "ERPC returned malformed JSON"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def stream(path, query = nil)
|
|
199
|
+
@adapter.stream(
|
|
200
|
+
url: url(path, query),
|
|
201
|
+
headers: @headers.merge("authorization" => "Bearer #{@credential}", "accept" => "text/event-stream"),
|
|
202
|
+
timeout: @timeout
|
|
203
|
+
)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
private
|
|
207
|
+
|
|
208
|
+
def url(path, query)
|
|
209
|
+
uri = URI.parse(URLs.with_path(endpoint, path))
|
|
210
|
+
pairs = []
|
|
211
|
+
(query || {}).each do |key, value|
|
|
212
|
+
next if value.nil?
|
|
213
|
+
|
|
214
|
+
if value.is_a?(Array)
|
|
215
|
+
value.each { |item| pairs << [key.to_s, item.to_s] }
|
|
216
|
+
else
|
|
217
|
+
rendered = value == true ? "true" : value == false ? "false" : value.to_s
|
|
218
|
+
pairs << [key.to_s, rendered]
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
uri.query = URI.encode_www_form(pairs) unless pairs.empty?
|
|
222
|
+
uri.to_s
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "digest/sha1"
|
|
5
|
+
require "json"
|
|
6
|
+
require "openssl"
|
|
7
|
+
require "securerandom"
|
|
8
|
+
require "socket"
|
|
9
|
+
require "thread"
|
|
10
|
+
require "timeout"
|
|
11
|
+
require "uri"
|
|
12
|
+
|
|
13
|
+
module ERPC
|
|
14
|
+
class WebSocketConnection
|
|
15
|
+
MAX_MESSAGE_SIZE = 16 * 1024 * 1024
|
|
16
|
+
private_constant :MAX_MESSAGE_SIZE
|
|
17
|
+
|
|
18
|
+
def initialize(url, timeout)
|
|
19
|
+
@uri = URI.parse(url)
|
|
20
|
+
@timeout = timeout
|
|
21
|
+
@write_mutex = Mutex.new
|
|
22
|
+
connect
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def write_text(text)
|
|
26
|
+
write_frame(0x1, text.b)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def read_message
|
|
30
|
+
payload = +"".b
|
|
31
|
+
started = false
|
|
32
|
+
loop do
|
|
33
|
+
final, opcode, chunk = read_frame
|
|
34
|
+
case opcode
|
|
35
|
+
when 0x0
|
|
36
|
+
raise TransportError, "ERPC returned an invalid WebSocket frame" unless started
|
|
37
|
+
payload << chunk
|
|
38
|
+
when 0x1, 0x2
|
|
39
|
+
raise TransportError, "ERPC returned an invalid WebSocket frame" if started
|
|
40
|
+
started = true
|
|
41
|
+
payload << chunk
|
|
42
|
+
when 0x8
|
|
43
|
+
raise TransportError, "ERPC WebSocket connection closed"
|
|
44
|
+
when 0x9
|
|
45
|
+
write_frame(0xA, chunk)
|
|
46
|
+
next
|
|
47
|
+
when 0xA
|
|
48
|
+
next
|
|
49
|
+
else
|
|
50
|
+
raise TransportError, "ERPC returned an invalid WebSocket frame"
|
|
51
|
+
end
|
|
52
|
+
raise TransportError, "ERPC WebSocket message is too large" if payload.bytesize > MAX_MESSAGE_SIZE
|
|
53
|
+
return payload.force_encoding(Encoding::UTF_8) if final
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def close
|
|
58
|
+
write_frame(0x8, "".b)
|
|
59
|
+
rescue StandardError
|
|
60
|
+
nil
|
|
61
|
+
ensure
|
|
62
|
+
@socket&.close
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def connect
|
|
68
|
+
unless %w[ws wss].include?(@uri.scheme) && @uri.host
|
|
69
|
+
raise ConfigError, "WebSocket endpoint must be an absolute WS(S) URL"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
tcp = Timeout.timeout(@timeout) { TCPSocket.new(@uri.host, @uri.port) }
|
|
73
|
+
@socket = if @uri.scheme == "wss"
|
|
74
|
+
context = OpenSSL::SSL::SSLContext.new
|
|
75
|
+
context.set_params
|
|
76
|
+
ssl = OpenSSL::SSL::SSLSocket.new(tcp, context)
|
|
77
|
+
ssl.hostname = @uri.host if ssl.respond_to?(:hostname=)
|
|
78
|
+
ssl.sync_close = true
|
|
79
|
+
Timeout.timeout(@timeout) { ssl.connect }
|
|
80
|
+
ssl
|
|
81
|
+
else
|
|
82
|
+
tcp
|
|
83
|
+
end
|
|
84
|
+
handshake
|
|
85
|
+
rescue ::Timeout::Error
|
|
86
|
+
tcp&.close
|
|
87
|
+
raise TimeoutError, @timeout
|
|
88
|
+
rescue ConfigError, TimeoutError
|
|
89
|
+
tcp&.close
|
|
90
|
+
raise
|
|
91
|
+
rescue IOError, EOFError, SocketError, SystemCallError, OpenSSL::SSL::SSLError
|
|
92
|
+
tcp&.close
|
|
93
|
+
raise TransportError, "Unable to reach ERPC WebSocket"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def handshake
|
|
97
|
+
key = Base64.strict_encode64(SecureRandom.random_bytes(16))
|
|
98
|
+
host = @uri.host
|
|
99
|
+
default_port = (@uri.scheme == "wss" ? 443 : 80)
|
|
100
|
+
host = "#{host}:#{@uri.port}" unless @uri.port == default_port
|
|
101
|
+
request = [
|
|
102
|
+
"GET #{@uri.request_uri} HTTP/1.1",
|
|
103
|
+
"Host: #{host}",
|
|
104
|
+
"Upgrade: websocket",
|
|
105
|
+
"Connection: Upgrade",
|
|
106
|
+
"Sec-WebSocket-Key: #{key}",
|
|
107
|
+
"Sec-WebSocket-Version: 13",
|
|
108
|
+
"\r\n"
|
|
109
|
+
].join("\r\n")
|
|
110
|
+
Timeout.timeout(@timeout) { @socket.write(request) }
|
|
111
|
+
headers = read_until("\r\n\r\n", 64 * 1024)
|
|
112
|
+
status, *lines = headers.split("\r\n")
|
|
113
|
+
raise TransportError, "ERPC rejected the WebSocket upgrade" unless status&.match?(%r{\AHTTP/1\.[01] 101\b})
|
|
114
|
+
|
|
115
|
+
response_headers = lines.filter_map do |line|
|
|
116
|
+
name, separator, value = line.partition(":")
|
|
117
|
+
[name.downcase, value.strip] unless separator.empty?
|
|
118
|
+
end.to_h
|
|
119
|
+
expected = Base64.strict_encode64(Digest::SHA1.digest("#{key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))
|
|
120
|
+
unless response_headers["upgrade"]&.downcase == "websocket" &&
|
|
121
|
+
response_headers["connection"]&.downcase&.split(/\s*,\s*/)&.include?("upgrade") &&
|
|
122
|
+
secure_compare(response_headers["sec-websocket-accept"].to_s, expected)
|
|
123
|
+
raise TransportError, "ERPC returned an invalid WebSocket upgrade"
|
|
124
|
+
end
|
|
125
|
+
rescue ::Timeout::Error
|
|
126
|
+
raise TimeoutError, @timeout
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def read_until(delimiter, limit)
|
|
130
|
+
value = +"".b
|
|
131
|
+
until value.end_with?(delimiter)
|
|
132
|
+
value << read_exact(1)
|
|
133
|
+
raise TransportError, "ERPC returned oversized WebSocket headers" if value.bytesize > limit
|
|
134
|
+
end
|
|
135
|
+
value
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def read_frame
|
|
139
|
+
header = read_exact(2).bytes
|
|
140
|
+
unless (header[0] & 0x70).zero?
|
|
141
|
+
raise TransportError, "ERPC returned an invalid WebSocket frame"
|
|
142
|
+
end
|
|
143
|
+
final = (header[0] & 0x80) != 0
|
|
144
|
+
opcode = header[0] & 0x0F
|
|
145
|
+
masked = (header[1] & 0x80) != 0
|
|
146
|
+
raise TransportError, "ERPC returned an invalid WebSocket frame" if masked
|
|
147
|
+
|
|
148
|
+
length = header[1] & 0x7F
|
|
149
|
+
length = read_exact(2).unpack1("n") if length == 126
|
|
150
|
+
length = read_exact(8).unpack1("Q>") if length == 127
|
|
151
|
+
if opcode >= 0x8 && (!final || length > 125)
|
|
152
|
+
raise TransportError, "ERPC returned an invalid WebSocket control frame"
|
|
153
|
+
end
|
|
154
|
+
raise TransportError, "ERPC WebSocket message is too large" if length > MAX_MESSAGE_SIZE
|
|
155
|
+
|
|
156
|
+
payload = read_exact(length)
|
|
157
|
+
[final, opcode, payload]
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def write_frame(opcode, payload)
|
|
161
|
+
mask = SecureRandom.random_bytes(4)
|
|
162
|
+
header = [0x80 | opcode]
|
|
163
|
+
length = payload.bytesize
|
|
164
|
+
if length < 126
|
|
165
|
+
header << (0x80 | length)
|
|
166
|
+
prefix = header.pack("C*")
|
|
167
|
+
elsif length <= 65_535
|
|
168
|
+
header << (0x80 | 126)
|
|
169
|
+
prefix = header.pack("C*") + [length].pack("n")
|
|
170
|
+
else
|
|
171
|
+
header << (0x80 | 127)
|
|
172
|
+
prefix = header.pack("C*") + [length].pack("Q>")
|
|
173
|
+
end
|
|
174
|
+
masked = payload.bytes.each_with_index.map { |byte, index| byte ^ mask.getbyte(index % 4) }.pack("C*")
|
|
175
|
+
@write_mutex.synchronize do
|
|
176
|
+
Timeout.timeout(@timeout) { @socket.write(prefix + mask + masked) }
|
|
177
|
+
end
|
|
178
|
+
rescue ::Timeout::Error
|
|
179
|
+
raise TimeoutError, @timeout
|
|
180
|
+
rescue IOError, EOFError, SocketError, SystemCallError, OpenSSL::SSL::SSLError
|
|
181
|
+
raise TransportError, "Unable to reach ERPC WebSocket"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def read_exact(length)
|
|
185
|
+
value = +"".b
|
|
186
|
+
while value.bytesize < length
|
|
187
|
+
ready = IO.select([@socket], nil, nil, @timeout)
|
|
188
|
+
raise TimeoutError, @timeout unless ready
|
|
189
|
+
|
|
190
|
+
chunk = @socket.readpartial(length - value.bytesize)
|
|
191
|
+
raise EOFError if chunk.empty?
|
|
192
|
+
|
|
193
|
+
value << chunk
|
|
194
|
+
end
|
|
195
|
+
value
|
|
196
|
+
rescue EOFError, IOError, SocketError, SystemCallError, OpenSSL::SSL::SSLError
|
|
197
|
+
raise TransportError, "ERPC WebSocket connection closed"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def secure_compare(left, right)
|
|
201
|
+
return false unless left.bytesize == right.bytesize
|
|
202
|
+
|
|
203
|
+
left.bytes.zip(right.bytes).reduce(0) { |difference, (a, b)| difference | (a ^ b) }.zero?
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
class WebSocketJsonRpcTransport
|
|
208
|
+
def initialize(connection_url, credential, timeout, connection_factory: nil)
|
|
209
|
+
@connection_url = connection_url
|
|
210
|
+
@credential = credential
|
|
211
|
+
@timeout = timeout
|
|
212
|
+
@connection_factory = connection_factory || ->(url, seconds) { WebSocketConnection.new(url, seconds) }
|
|
213
|
+
@mutex = Mutex.new
|
|
214
|
+
@connection = nil
|
|
215
|
+
@reader = nil
|
|
216
|
+
@pending = {}
|
|
217
|
+
@listeners = {}
|
|
218
|
+
@next_id = 0
|
|
219
|
+
@next_listener_id = 0
|
|
220
|
+
@closed = false
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def request(method, params = nil)
|
|
224
|
+
connection = ensure_connection
|
|
225
|
+
request_id = @mutex.synchronize { @next_id += 1 }
|
|
226
|
+
queue = Queue.new
|
|
227
|
+
@mutex.synchronize { @pending[request_id] = queue }
|
|
228
|
+
body = { "jsonrpc" => "2.0", "id" => request_id, "method" => method }
|
|
229
|
+
body["params"] = params unless params.nil?
|
|
230
|
+
connection.write_text(JSON.generate(body))
|
|
231
|
+
response = Timeout.timeout(@timeout) { queue.pop }
|
|
232
|
+
raise response if response.is_a?(Exception)
|
|
233
|
+
|
|
234
|
+
unwrap(response, request_id)
|
|
235
|
+
rescue ::Timeout::Error
|
|
236
|
+
raise TimeoutError, @timeout
|
|
237
|
+
ensure
|
|
238
|
+
@mutex.synchronize { @pending.delete(request_id) } if request_id
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def on_notification(listener = nil, &block)
|
|
242
|
+
selected = listener || block
|
|
243
|
+
raise ArgumentError, "listener is required" unless selected
|
|
244
|
+
|
|
245
|
+
listener_id = @mutex.synchronize do
|
|
246
|
+
@next_listener_id += 1
|
|
247
|
+
@listeners[@next_listener_id] = selected
|
|
248
|
+
@next_listener_id
|
|
249
|
+
end
|
|
250
|
+
-> { @mutex.synchronize { @listeners.delete(listener_id) } }
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def close
|
|
254
|
+
connection = @mutex.synchronize do
|
|
255
|
+
return if @closed
|
|
256
|
+
|
|
257
|
+
@closed = true
|
|
258
|
+
current, @connection = @connection, nil
|
|
259
|
+
current
|
|
260
|
+
end
|
|
261
|
+
connection&.close
|
|
262
|
+
@reader&.join(1)
|
|
263
|
+
fail_pending(TransportError.new("WebSocket transport is closed"))
|
|
264
|
+
@mutex.synchronize { @listeners.clear }
|
|
265
|
+
nil
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
private
|
|
269
|
+
|
|
270
|
+
def ensure_connection
|
|
271
|
+
@mutex.synchronize do
|
|
272
|
+
raise TransportError, "WebSocket transport is closed" if @closed
|
|
273
|
+
return @connection if @connection
|
|
274
|
+
|
|
275
|
+
@connection = @connection_factory.call(@connection_url, @timeout)
|
|
276
|
+
connection = @connection
|
|
277
|
+
@reader = Thread.new { read_loop(connection) }
|
|
278
|
+
@reader.report_on_exception = false
|
|
279
|
+
connection
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def read_loop(connection)
|
|
284
|
+
loop do
|
|
285
|
+
value = JSON.parse(connection.read_message)
|
|
286
|
+
next unless value.is_a?(Hash)
|
|
287
|
+
|
|
288
|
+
id = value["id"]
|
|
289
|
+
if id.is_a?(Integer) || id.is_a?(String)
|
|
290
|
+
queue = @mutex.synchronize { @pending[id] }
|
|
291
|
+
queue << value if queue
|
|
292
|
+
elsif value["method"].is_a?(String) && value.key?("params")
|
|
293
|
+
listeners = @mutex.synchronize { @listeners.values.dup }
|
|
294
|
+
listeners.each do |listener|
|
|
295
|
+
listener.call(value)
|
|
296
|
+
rescue StandardError
|
|
297
|
+
next
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
rescue JSON::ParserError
|
|
301
|
+
next
|
|
302
|
+
end
|
|
303
|
+
rescue StandardError => error
|
|
304
|
+
@mutex.synchronize { @connection = nil if @connection.equal?(connection) }
|
|
305
|
+
fail_pending(TransportError.new(Redaction.text(error.message, @credential)))
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def fail_pending(error)
|
|
309
|
+
queues = @mutex.synchronize { @pending.values.dup }
|
|
310
|
+
queues.each { |queue| queue << error }
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def unwrap(response, expected_id)
|
|
314
|
+
unless response.is_a?(Hash) && response["id"] == expected_id
|
|
315
|
+
raise InvalidResponseError, "ERPC returned an invalid WebSocket response"
|
|
316
|
+
end
|
|
317
|
+
if response.key?("error")
|
|
318
|
+
error = response["error"]
|
|
319
|
+
unless error.is_a?(Hash) && error["code"].is_a?(Integer) && error["message"].is_a?(String)
|
|
320
|
+
raise InvalidResponseError, "ERPC returned an invalid RPC error"
|
|
321
|
+
end
|
|
322
|
+
data = error.key?("data") ? Redaction.value(error["data"], @credential) : nil
|
|
323
|
+
raise JsonRpcError.new(error["code"], Redaction.text(error["message"], @credential), data)
|
|
324
|
+
end
|
|
325
|
+
raise InvalidResponseError, "ERPC returned an invalid WebSocket response" unless response.key?("result")
|
|
326
|
+
|
|
327
|
+
response["result"]
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
class RpcSubscription
|
|
332
|
+
include Enumerable
|
|
333
|
+
|
|
334
|
+
attr_reader :id
|
|
335
|
+
|
|
336
|
+
def initialize(id, queue, unsubscribe)
|
|
337
|
+
@id = id
|
|
338
|
+
@queue = queue
|
|
339
|
+
@unsubscribe = unsubscribe
|
|
340
|
+
@closed = false
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def each
|
|
344
|
+
return enum_for(:each) unless block_given?
|
|
345
|
+
|
|
346
|
+
loop { yield self.next }
|
|
347
|
+
rescue StopIteration
|
|
348
|
+
self
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def next(timeout: nil)
|
|
352
|
+
raise StopIteration if @closed && @queue.empty?
|
|
353
|
+
|
|
354
|
+
return @queue.pop if timeout.nil?
|
|
355
|
+
|
|
356
|
+
Timeout.timeout(timeout) { @queue.pop }
|
|
357
|
+
rescue ::Timeout::Error
|
|
358
|
+
raise TimeoutError, timeout
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def unsubscribe
|
|
362
|
+
return true if @closed
|
|
363
|
+
|
|
364
|
+
result = @unsubscribe.call
|
|
365
|
+
@closed = true if result
|
|
366
|
+
result
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
class SubscriptionsBase
|
|
371
|
+
def initialize(transport)
|
|
372
|
+
@transport = transport
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def on_notification(listener = nil, &block)
|
|
376
|
+
@transport.on_notification(listener, &block)
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def raw(method, params = nil)
|
|
380
|
+
@transport.request(method, params)
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def close
|
|
384
|
+
@transport.close
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
private
|
|
388
|
+
|
|
389
|
+
def subscribe_to(subscribe_method, params, unsubscribe_method, listener)
|
|
390
|
+
queue = Queue.new
|
|
391
|
+
subscription_id = nil
|
|
392
|
+
remove = @transport.on_notification do |notification|
|
|
393
|
+
raw_params = notification["params"]
|
|
394
|
+
next unless raw_params.is_a?(Hash) && raw_params["subscription"] == subscription_id && raw_params.key?("result")
|
|
395
|
+
|
|
396
|
+
result = raw_params["result"]
|
|
397
|
+
queue << result
|
|
398
|
+
listener&.call(result)
|
|
399
|
+
end
|
|
400
|
+
subscription_id = @transport.request(subscribe_method, params)
|
|
401
|
+
unless subscription_id.is_a?(Integer) || subscription_id.is_a?(String)
|
|
402
|
+
remove.call
|
|
403
|
+
raise InvalidResponseError, "ERPC returned an invalid subscription id"
|
|
404
|
+
end
|
|
405
|
+
unsubscribe = lambda do
|
|
406
|
+
result = @transport.request(unsubscribe_method, [subscription_id])
|
|
407
|
+
raise InvalidResponseError, "ERPC returned an invalid unsubscribe result" unless [true, false].include?(result)
|
|
408
|
+
|
|
409
|
+
remove.call if result
|
|
410
|
+
result
|
|
411
|
+
end
|
|
412
|
+
RpcSubscription.new(subscription_id, queue, unsubscribe)
|
|
413
|
+
rescue StandardError
|
|
414
|
+
remove&.call
|
|
415
|
+
raise
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
class EthereumSubscriptions < SubscriptionsBase
|
|
420
|
+
def subscribe(subscription, *options, listener: nil, &block)
|
|
421
|
+
result = subscribe_to("eth_subscribe", [subscription, *options], "eth_unsubscribe", listener || block)
|
|
422
|
+
unless result.id.is_a?(String)
|
|
423
|
+
result.unsubscribe
|
|
424
|
+
raise InvalidResponseError, "ERPC returned an invalid subscription id"
|
|
425
|
+
end
|
|
426
|
+
result
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
class SolanaSubscriptions < SubscriptionsBase
|
|
431
|
+
def account_subscribe(address, options = nil, listener: nil, &block)
|
|
432
|
+
params = [address]
|
|
433
|
+
params << options unless options.nil?
|
|
434
|
+
numeric_subscribe("accountSubscribe", params, "accountUnsubscribe", listener || block)
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def transaction_subscribe(filter, options = nil, listener: nil, &block)
|
|
438
|
+
params = [filter]
|
|
439
|
+
params << options unless options.nil?
|
|
440
|
+
numeric_subscribe("transactionSubscribe", params, "transactionUnsubscribe", listener || block)
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def raw_subscribe(subscribe_method, params, unsubscribe_method, listener: nil, &block)
|
|
444
|
+
subscribe_to(subscribe_method, params, unsubscribe_method, listener || block)
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
private
|
|
448
|
+
|
|
449
|
+
def numeric_subscribe(subscribe_method, params, unsubscribe_method, listener)
|
|
450
|
+
result = subscribe_to(subscribe_method, params, unsubscribe_method, listener)
|
|
451
|
+
unless result.id.is_a?(Integer)
|
|
452
|
+
result.unsubscribe
|
|
453
|
+
raise InvalidResponseError, "ERPC returned an invalid subscription id"
|
|
454
|
+
end
|
|
455
|
+
result
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
end
|
data/lib/erpc_sdk.rb
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "erpc_sdk/version"
|
|
4
|
+
require_relative "erpc_sdk/errors"
|
|
5
|
+
require_relative "erpc_sdk/config"
|
|
6
|
+
require_relative "erpc_sdk/transport"
|
|
7
|
+
require_relative "erpc_sdk/rpc"
|
|
8
|
+
require_relative "erpc_sdk/rest"
|
|
9
|
+
require_relative "erpc_sdk/websocket"
|
|
10
|
+
require_relative "erpc_sdk/client"
|