eodhd 0.19.13 → 0.20.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 +4 -4
- data/CHANGELOG +13 -0
- data/eodhd.gemspec +1 -1
- data/lib/Eodhd/VERSION.rb +1 -1
- data/lib/Eodhd/WebSocketClient.rb +57 -12
- data/test/Eodhd/WebSocketClient_test.rb +105 -12
- data/test/gemspec_test.rb +1 -1
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9ffb5b10a7b76ed5cbf348871d4a52e632928745e47be2da832025c190385f4e
|
|
4
|
+
data.tar.gz: 7c2b748ba0bbb9c9c10700a705d73540cf34e9cb02785bd52b78053d818cbcb0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 275a4e69b3d081bbe5f680f7afb8ab4e63799753a13a386abdcf2f5431357e15c5c935acef513cc2c6266031843e194e72829a18d9381589ca349c7d2402a6e4
|
|
7
|
+
data.tar.gz: 96be2135eb222e8b0d3b006baf8633ffd12fc7639c44d7358c5f4edd1f050b7bedd8e626c57e92ab4d176354179d4c3acad01066a6d0a8892bc5fca642d797b4
|
data/CHANGELOG
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
## 20261024
|
|
4
4
|
|
|
5
|
+
0.20.0: Replace iodine with websocket-driver, so as WebSocketClient sends SNI.
|
|
6
|
+
|
|
7
|
+
1. ~ eodhd.gemspec and eodhd.rb.gemspec: spec.dependencies: - iodine, + websocket-driver. EODHD's WebSocket endpoints refuse a TLS handshake without SNI, which neither iodine 0.7.60 nor 0.8.2 sends, and 0.8.2 also leaves the API token's query string off the request.
|
|
8
|
+
2. + Eodhd::WebSocketClient::Connection, the URL and socket for WebSocket::Driver.
|
|
9
|
+
3. + Eodhd::WebSocketClient#tls_socket, #tls_context: naming the host (SNI) and verifying its certificate.
|
|
10
|
+
4. + Eodhd::WebSocketClient#websocket_driver
|
|
11
|
+
5. ~ Eodhd::WebSocketClient#run: reads into a WebSocket::Driver until it closes, or until EOFError, Errno::EPIPE or Errno::ECONNRESET. An exception from the consumer, or an Eodhd::Error for a frame which isn't JSON, now ends run, where iodine printed it and carried on.
|
|
12
|
+
6. ~ Eodhd::WebSocketClient::Handler#on_open: subscribes with WebSocket::Driver#text.
|
|
13
|
+
7. - Eodhd::WebSocketClient::Handler#on_close
|
|
14
|
+
8. ~ test/Eodhd/WebSocketClient_test.rb: #run against a server on a socket pair, rather than with Iodine stubbed; + #tls_socket against a local TLS server; + Handler#on_open.
|
|
15
|
+
9. ~ test/gemspec_test.rb: "declares its runtime dependencies": - iodine, + websocket-driver.
|
|
16
|
+
10. ~ Eodhd::VERSION: /0.19.13/0.20.0/
|
|
17
|
+
|
|
5
18
|
0.19.13: Fix WebSocketClient.us_trade, .us_quote, .forex and .crypto.
|
|
6
19
|
|
|
7
20
|
1. ~ Eodhd::WebSocketClient.us_trade, .us_quote, .forex and .crypto: /self.class.new/new/. Within class << self, self.class is Class, so each raised TypeError.
|
data/eodhd.gemspec
CHANGED
data/lib/Eodhd/VERSION.rb
CHANGED
|
@@ -7,9 +7,12 @@
|
|
|
7
7
|
# ii. US market quote data: wss://ws.eodhistoricaldata.com/ws/us-quote?api_token=XXX
|
|
8
8
|
# iii. Forex: wss://ws.eodhistoricaldata.com/ws/forex?api_token=XXX
|
|
9
9
|
# iv. Crypto: wss://ws.eodhistoricaldata.com/ws/crypto?api_token=XXX
|
|
10
|
+
# 2. The endpoints refuse a TLS handshake which doesn't name the host (SNI).
|
|
10
11
|
|
|
11
|
-
require 'iodine'
|
|
12
12
|
require 'json'
|
|
13
|
+
require 'openssl'
|
|
14
|
+
require 'socket'
|
|
15
|
+
require 'websocket/driver'
|
|
13
16
|
|
|
14
17
|
require_relative './DefaultLogger'
|
|
15
18
|
require_relative './Error'
|
|
@@ -17,21 +20,35 @@ require_relative './Error'
|
|
|
17
20
|
class Eodhd
|
|
18
21
|
class WebSocketClient
|
|
19
22
|
API_HOST = 'ws.eodhistoricaldata.com'
|
|
23
|
+
API_PORT = 443
|
|
24
|
+
READ_SIZE = 16384
|
|
25
|
+
|
|
26
|
+
# What WebSocket::Driver writes to: the URL it asks for and the socket to write to.
|
|
27
|
+
class Connection
|
|
28
|
+
attr_reader :url
|
|
29
|
+
|
|
30
|
+
def write(data)
|
|
31
|
+
@socket.write(data)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def initialize(url:, socket:)
|
|
37
|
+
@url = url
|
|
38
|
+
@socket = socket
|
|
39
|
+
end
|
|
40
|
+
end
|
|
20
41
|
|
|
21
42
|
class Handler
|
|
22
|
-
def on_open(
|
|
23
|
-
|
|
43
|
+
def on_open(driver)
|
|
44
|
+
driver.text({action: 'subscribe', symbols: @symbols}.to_json)
|
|
24
45
|
end
|
|
25
46
|
|
|
26
|
-
def on_message(
|
|
47
|
+
def on_message(driver, message)
|
|
27
48
|
dataframe = handle_response(message)
|
|
28
49
|
@consumer.call(dataframe) if @consumer
|
|
29
50
|
end
|
|
30
51
|
|
|
31
|
-
def on_close(connection)
|
|
32
|
-
Iodine.stop
|
|
33
|
-
end
|
|
34
|
-
|
|
35
52
|
private
|
|
36
53
|
|
|
37
54
|
def initialize(symbols:, consumer:, logger: nil)
|
|
@@ -94,11 +111,14 @@ class Eodhd
|
|
|
94
111
|
end
|
|
95
112
|
|
|
96
113
|
def run
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
114
|
+
socket = tls_socket
|
|
115
|
+
driver = websocket_driver(socket)
|
|
116
|
+
driver.start
|
|
117
|
+
driver.parse(socket.readpartial(READ_SIZE)) until driver.state == :closed
|
|
118
|
+
rescue EOFError, Errno::EPIPE, Errno::ECONNRESET, SystemExit, Interrupt
|
|
101
119
|
return
|
|
120
|
+
ensure
|
|
121
|
+
socket&.close
|
|
102
122
|
end
|
|
103
123
|
|
|
104
124
|
private
|
|
@@ -126,5 +146,30 @@ class Eodhd
|
|
|
126
146
|
def url
|
|
127
147
|
"wss://#{API_HOST}#{self.class.path_prefix}/#{@asset_class}?api_token=#{@api_token}"
|
|
128
148
|
end
|
|
149
|
+
|
|
150
|
+
def tls_context
|
|
151
|
+
context = OpenSSL::SSL::SSLContext.new
|
|
152
|
+
context.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER)
|
|
153
|
+
context
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def tls_socket(host: API_HOST, port: API_PORT)
|
|
157
|
+
socket = OpenSSL::SSL::SSLSocket.new(TCPSocket.new(host, port), tls_context)
|
|
158
|
+
socket.hostname = host
|
|
159
|
+
socket.sync_close = true
|
|
160
|
+
socket.connect
|
|
161
|
+
socket
|
|
162
|
+
rescue OpenSSL::SSL::SSLError
|
|
163
|
+
socket.close
|
|
164
|
+
raise
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def websocket_driver(socket)
|
|
168
|
+
driver = WebSocket::Driver.client(Connection.new(url: url, socket: socket))
|
|
169
|
+
handler = Handler.new(symbols: @symbols, consumer: @consumer, logger: @logger)
|
|
170
|
+
driver.on(:open){handler.on_open(driver)}
|
|
171
|
+
driver.on(:message){|event| handler.on_message(driver, event.data)}
|
|
172
|
+
driver
|
|
173
|
+
end
|
|
129
174
|
end
|
|
130
175
|
end
|
|
@@ -93,26 +93,119 @@ describe Eodhd::WebSocketClient do
|
|
|
93
93
|
end
|
|
94
94
|
|
|
95
95
|
describe "#run" do
|
|
96
|
-
|
|
97
|
-
|
|
96
|
+
# The server's half of the opening handshake (RFC 6455, 4.2.2), answering with the request line.
|
|
97
|
+
def accept_handshake(socket)
|
|
98
|
+
request = []
|
|
99
|
+
request << socket.gets.chomp until request.last == ''
|
|
100
|
+
key = request.grep(/\ASec-WebSocket-Key:/i).first.split(':', 2).last.strip
|
|
101
|
+
accept = [OpenSSL::Digest.digest('SHA1', key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')].pack('m0')
|
|
102
|
+
socket.write("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: #{accept}\r\n\r\n")
|
|
103
|
+
request.first
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# A masked client frame of under 126 bytes.
|
|
107
|
+
def read_client_frame(socket)
|
|
108
|
+
length = socket.read(2).bytes.last & 0x7f
|
|
109
|
+
mask = socket.read(4).bytes
|
|
110
|
+
socket.read(length).bytes.each_with_index.map{|byte, i| byte ^ mask[i % 4]}.pack('C*')
|
|
111
|
+
end
|
|
98
112
|
|
|
99
|
-
|
|
100
|
-
|
|
113
|
+
def text_frame(text)
|
|
114
|
+
[0x81, text.bytesize].pack('CC') + text
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def close_frame
|
|
118
|
+
[0x88, 2, 1000].pack('CCn')
|
|
119
|
+
end
|
|
101
120
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
121
|
+
it "subscribes, hands each message to the consumer, and returns once the server closes" do
|
|
122
|
+
received = []
|
|
123
|
+
client = Eodhd::WebSocketClient.new(api_token: 'api_token', asset_class: 'crypto', symbols: 'BTC-USD', consumer: ->(data){received << data})
|
|
124
|
+
client_socket, server_socket = UNIXSocket.pair
|
|
125
|
+
server = Thread.new do
|
|
126
|
+
request_line = accept_handshake(server_socket)
|
|
127
|
+
subscription = read_client_frame(server_socket)
|
|
128
|
+
server_socket.write(text_frame('{"s":"BTC-USD","p":"81430.24"}'))
|
|
129
|
+
server_socket.write(close_frame)
|
|
130
|
+
[request_line, subscription]
|
|
108
131
|
end
|
|
132
|
+
client.stub(:tls_socket, client_socket){client.run}
|
|
133
|
+
request_line, subscription = server.value
|
|
134
|
+
_(request_line).must_equal('GET /ws/crypto?api_token=api_token HTTP/1.1')
|
|
135
|
+
_(JSON.parse(subscription)).must_equal({'action' => 'subscribe', 'symbols' => 'BTC-USD'})
|
|
136
|
+
_(received).must_equal([{'s' => 'BTC-USD', 'p' => '81430.24'}])
|
|
137
|
+
end
|
|
109
138
|
|
|
110
|
-
|
|
111
|
-
|
|
139
|
+
it "returns when the server drops the connection" do
|
|
140
|
+
received = []
|
|
141
|
+
client = Eodhd::WebSocketClient.new(api_token: 'api_token', asset_class: 'crypto', symbols: 'BTC-USD', consumer: ->(data){received << data})
|
|
142
|
+
client_socket, server_socket = UNIXSocket.pair
|
|
143
|
+
server = Thread.new do
|
|
144
|
+
accept_handshake(server_socket)
|
|
145
|
+
server_socket.close
|
|
146
|
+
end
|
|
147
|
+
client.stub(:tls_socket, client_socket){client.run}
|
|
148
|
+
server.join
|
|
149
|
+
_(received).must_equal([])
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
describe "#tls_socket" do
|
|
154
|
+
def self_signed_certificate(key)
|
|
155
|
+
certificate = OpenSSL::X509::Certificate.new
|
|
156
|
+
certificate.version = 2
|
|
157
|
+
certificate.serial = 1
|
|
158
|
+
certificate.subject = certificate.issuer = OpenSSL::X509::Name.parse('/CN=localhost')
|
|
159
|
+
certificate.public_key = key.public_key
|
|
160
|
+
certificate.not_before = Time.now - 60
|
|
161
|
+
certificate.not_after = Time.now + 3600
|
|
162
|
+
certificate.sign(key, OpenSSL::Digest.new('SHA256'))
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Yields the port of a local TLS server with a self-signed certificate, and the host names its clients sent.
|
|
166
|
+
def with_tls_server
|
|
167
|
+
key = OpenSSL::PKey::RSA.new(2048)
|
|
168
|
+
context = OpenSSL::SSL::SSLContext.new
|
|
169
|
+
context.cert, context.key = self_signed_certificate(key), key
|
|
170
|
+
server_names = []
|
|
171
|
+
context.servername_cb = ->((socket, name)){server_names << name; nil}
|
|
172
|
+
tcp_server = TCPServer.new('localhost', 0)
|
|
173
|
+
ssl_server = OpenSSL::SSL::SSLServer.new(tcp_server, context)
|
|
174
|
+
server = Thread.new{ssl_server.accept rescue nil}
|
|
175
|
+
yield tcp_server.addr[1], server_names
|
|
176
|
+
ensure
|
|
177
|
+
server&.join(5)
|
|
178
|
+
ssl_server&.close
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
it "names the host in the TLS handshake (SNI)" do
|
|
182
|
+
client = Eodhd::WebSocketClient.new(api_token: api_token, asset_class: 'crypto', symbols: 'BTC-USD', consumer: consumer)
|
|
183
|
+
with_tls_server do |port, server_names|
|
|
184
|
+
_{client.send(:tls_socket, host: 'localhost', port: port)}.must_raise(OpenSSL::SSL::SSLError)
|
|
185
|
+
_(server_names).must_equal(['localhost'])
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
it "refuses a certificate it can't verify" do
|
|
190
|
+
client = Eodhd::WebSocketClient.new(api_token: api_token, asset_class: 'crypto', symbols: 'BTC-USD', consumer: consumer)
|
|
191
|
+
with_tls_server do |port, server_names|
|
|
192
|
+
error = _{client.send(:tls_socket, host: 'localhost', port: port)}.must_raise(OpenSSL::SSL::SSLError)
|
|
193
|
+
_(error.message).must_match(/certificate verify failed/)
|
|
194
|
+
end
|
|
112
195
|
end
|
|
113
196
|
end
|
|
114
197
|
|
|
115
198
|
describe "Handler" do
|
|
199
|
+
describe "#on_open" do
|
|
200
|
+
it "subscribes to its symbols" do
|
|
201
|
+
sent = []
|
|
202
|
+
driver = Struct.new(:sent){def text(message) = sent << message}.new(sent)
|
|
203
|
+
handler = Eodhd::WebSocketClient::Handler.new(symbols: 'AAPL,MSFT', consumer: consumer, logger: nil)
|
|
204
|
+
handler.on_open(driver)
|
|
205
|
+
_(sent.collect{|message| JSON.parse(message)}).must_equal([{'action' => 'subscribe', 'symbols' => 'AAPL,MSFT'}])
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
116
209
|
describe "#on_message" do
|
|
117
210
|
it "parses JSON and calls consumer" do
|
|
118
211
|
received_data = nil
|
data/test/gemspec_test.rb
CHANGED
|
@@ -23,7 +23,7 @@ require_relative './helper'
|
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
it "declares its runtime dependencies" do
|
|
26
|
-
_(spec.runtime_dependencies.collect(&:name).sort).must_equal(%w{http.rb
|
|
26
|
+
_(spec.runtime_dependencies.collect(&:name).sort).must_equal(%w{http.rb logger websocket-driver})
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
it "requires http.rb 1.7.0 or later, whose load file is lib/http.rb" do
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: eodhd
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.20.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- thoran
|
|
@@ -24,7 +24,7 @@ dependencies:
|
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: 1.7.0
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
|
-
name:
|
|
27
|
+
name: logger
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
29
29
|
requirements:
|
|
30
30
|
- - ">="
|
|
@@ -38,7 +38,7 @@ dependencies:
|
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
39
|
version: '0'
|
|
40
40
|
- !ruby/object:Gem::Dependency
|
|
41
|
-
name:
|
|
41
|
+
name: websocket-driver
|
|
42
42
|
requirement: !ruby/object:Gem::Requirement
|
|
43
43
|
requirements:
|
|
44
44
|
- - ">="
|