eodhd 0.19.12 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 530f57a120775f0c6624020d4f445306b4dfa33d7c12e9a7f67f2b44df33c9e7
4
- data.tar.gz: 5e4888b2cbeb8f453e3c936062c292b872f440fc3cef7a6727a1b69f976127de
3
+ metadata.gz: 9ffb5b10a7b76ed5cbf348871d4a52e632928745e47be2da832025c190385f4e
4
+ data.tar.gz: 7c2b748ba0bbb9c9c10700a705d73540cf34e9cb02785bd52b78053d818cbcb0
5
5
  SHA512:
6
- metadata.gz: ef65941abdbbbaf07a93107448f290ef8a14041de4eca5f802a48276f73e6c40c44dcf615e654397cfac72b9004f5a10dd53f7c9975933be5e94b98dc27d10aa
7
- data.tar.gz: 51efb1a993e9d299a8f2308842fd81e5cd7e66fd8a0eca28330faf013577ad38847ff499672faea85858325d42c5b9334595db045634773c744a620b2295a3fe
6
+ metadata.gz: 275a4e69b3d081bbe5f680f7afb8ab4e63799753a13a386abdcf2f5431357e15c5c935acef513cc2c6266031843e194e72829a18d9381589ca349c7d2402a6e4
7
+ data.tar.gz: 96be2135eb222e8b0d3b006baf8633ffd12fc7639c44d7358c5f4edd1f050b7bedd8e626c57e92ab4d176354179d4c3acad01066a6d0a8892bc5fca642d797b4
data/CHANGELOG CHANGED
@@ -1,5 +1,27 @@
1
1
  # eodhd/CHANGELOG
2
2
 
3
+ ## 20261024
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
+
18
+ 0.19.13: Fix WebSocketClient.us_trade, .us_quote, .forex and .crypto.
19
+
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.
21
+ 2. + test/Eodhd/WebSocketClient_test.rb: that .us_trade, .us, .us_quote, .forex and .crypto each make a client for their stream.
22
+ 3. ~ Eodhd::VERSION: /0.19.12/0.19.13/
23
+
24
+
3
25
  ## 20261021
4
26
 
5
27
  0.19.12: Require http.rb >= 1.7.0, for case-sensitive filesystems.
data/eodhd.gemspec CHANGED
@@ -38,8 +38,8 @@ Gem::Specification.new do |spec|
38
38
 
39
39
  spec.dependencies = [
40
40
  ['http.rb', '>= 1.7.0'],
41
- 'iodine',
42
41
  'logger',
42
+ 'websocket-driver',
43
43
  ]
44
44
 
45
45
  spec.development_dependencies = %w{
data/lib/Eodhd/VERSION.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  class Eodhd
2
- VERSION = '0.19.12'
2
+ VERSION = '0.20.0'
3
3
  end
@@ -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(connection)
23
- connection.write({action: 'subscribe', symbols: @symbols}.to_json)
43
+ def on_open(driver)
44
+ driver.text({action: 'subscribe', symbols: @symbols}.to_json)
24
45
  end
25
46
 
26
- def on_message(connection, 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)
@@ -63,20 +80,20 @@ class Eodhd
63
80
  end
64
81
 
65
82
  def us_trade(api_token:, symbols:, consumer:)
66
- self.class.new(api_token: api_token, asset_class: 'us', symbols: symbols, consumer: consumer)
83
+ new(api_token: api_token, asset_class: 'us', symbols: symbols, consumer: consumer)
67
84
  end
68
85
  alias_method :us, :us_trade
69
86
 
70
87
  def us_quote(api_token:, symbols:, consumer:)
71
- self.class.new(api_token: api_token, asset_class: 'us-quote', symbols: symbols, consumer: consumer)
88
+ new(api_token: api_token, asset_class: 'us-quote', symbols: symbols, consumer: consumer)
72
89
  end
73
90
 
74
91
  def forex(api_token:, symbols:, consumer:)
75
- self.class.new(api_token: api_token, asset_class: 'forex', symbols: symbols, consumer: consumer)
92
+ new(api_token: api_token, asset_class: 'forex', symbols: symbols, consumer: consumer)
76
93
  end
77
94
 
78
95
  def crypto(api_token:, symbols:, consumer:)
79
- self.class.new(api_token: api_token, asset_class: 'crypto', symbols: symbols, consumer: consumer)
96
+ new(api_token: api_token, asset_class: 'crypto', symbols: symbols, consumer: consumer)
80
97
  end
81
98
  end # class << self
82
99
 
@@ -94,11 +111,14 @@ class Eodhd
94
111
  end
95
112
 
96
113
  def run
97
- Iodine.threads = 1
98
- Iodine.connect(url: url, handler: Handler.new(symbols: @symbols, consumer: @consumer, logger: @logger))
99
- Iodine.start
100
- rescue SystemExit, Interrupt
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
@@ -6,6 +6,46 @@ describe Eodhd::WebSocketClient do
6
6
  let(:api_token){ENV.fetch('EODHD_API_TOKEN', '<API_TOKEN>')}
7
7
  let(:consumer){->(data){}}
8
8
 
9
+ describe ".us_trade" do
10
+ it "makes a client for the US trade stream" do
11
+ client = Eodhd::WebSocketClient.us_trade(api_token: api_token, symbols: 'AAPL', consumer: consumer)
12
+ _(client).must_be_instance_of(Eodhd::WebSocketClient)
13
+ _(client.asset_class).must_equal('us')
14
+ end
15
+ end
16
+
17
+ describe ".us" do
18
+ it "makes a client for the US trade stream" do
19
+ client = Eodhd::WebSocketClient.us(api_token: api_token, symbols: 'AAPL', consumer: consumer)
20
+ _(client).must_be_instance_of(Eodhd::WebSocketClient)
21
+ _(client.asset_class).must_equal('us')
22
+ end
23
+ end
24
+
25
+ describe ".us_quote" do
26
+ it "makes a client for the US quote stream" do
27
+ client = Eodhd::WebSocketClient.us_quote(api_token: api_token, symbols: 'AAPL', consumer: consumer)
28
+ _(client).must_be_instance_of(Eodhd::WebSocketClient)
29
+ _(client.asset_class).must_equal('us-quote')
30
+ end
31
+ end
32
+
33
+ describe ".forex" do
34
+ it "makes a client for the forex stream" do
35
+ client = Eodhd::WebSocketClient.forex(api_token: api_token, symbols: 'EURUSD', consumer: consumer)
36
+ _(client).must_be_instance_of(Eodhd::WebSocketClient)
37
+ _(client.asset_class).must_equal('forex')
38
+ end
39
+ end
40
+
41
+ describe ".crypto" do
42
+ it "makes a client for the crypto stream" do
43
+ client = Eodhd::WebSocketClient.crypto(api_token: api_token, symbols: 'BTC-USD', consumer: consumer)
44
+ _(client).must_be_instance_of(Eodhd::WebSocketClient)
45
+ _(client.asset_class).must_equal('crypto')
46
+ end
47
+ end
48
+
9
49
  describe "#initialize" do
10
50
  it "accepts array of symbols and formats to comma-separated string" do
11
51
  client = Eodhd::WebSocketClient.new(api_token: api_token, asset_class: 'us', symbols: ['AAPL', 'MSFT'], consumer: consumer)
@@ -53,26 +93,119 @@ describe Eodhd::WebSocketClient do
53
93
  end
54
94
 
55
95
  describe "#run" do
56
- it "configures Iodine and starts connection" do
57
- client = Eodhd::WebSocketClient.new(api_token: api_token, asset_class: 'us', symbols: 'AAPL', consumer: consumer)
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
112
+
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
120
+
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]
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
138
+
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
58
152
 
59
- iodine_connect_called = false
60
- iodine_start_called = false
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
61
180
 
62
- Iodine.stub(:threads=, nil) do
63
- Iodine.stub(:connect, ->(opts){iodine_connect_called = true}) do
64
- Iodine.stub(:start, ->{iodine_start_called = true}) do
65
- client.run
66
- end
67
- end
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'])
68
186
  end
187
+ end
69
188
 
70
- _(iodine_connect_called).must_equal(true)
71
- _(iodine_start_called).must_equal(true)
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
72
195
  end
73
196
  end
74
197
 
75
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
+
76
209
  describe "#on_message" do
77
210
  it "parses JSON and calls consumer" do
78
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 iodine logger})
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.19.12
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: iodine
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: logger
41
+ name: websocket-driver
42
42
  requirement: !ruby/object:Gem::Requirement
43
43
  requirements:
44
44
  - - ">="