lightstreamer 0.2 → 0.3

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
  SHA1:
3
- metadata.gz: 7dd1721bffbb480dfa570531e520c727084a58d9
4
- data.tar.gz: 67ee866151859546536fca838dabfe05e84a1d2d
3
+ metadata.gz: f96a70c42b710644985ba440779ec2d73f32ad9e
4
+ data.tar.gz: 4c0181a0612a67870d3333671ecbb20e0af27f8c
5
5
  SHA512:
6
- metadata.gz: 0e52c034fa5c273733b97373038f1ccb9ac18959b9ab8c47b47c515ce64a954f3b397966cb02d5e924183364ec47d0402454faf117e4dbef3d3451fb5cfdaafa
7
- data.tar.gz: 8a79799ea6def575bd5aed0bbbe9d514512d4db4bd32b5dd6417a320d8b929739931714403f4312b3316f326280dc049c15ca883c5af929be9ba92c62687451a
6
+ metadata.gz: 5048265eaf80652222323ffad4f4fa4a39343c4adbc8a52ae5b449d43a8c63e343334cab654a2f6fdc3321955b418581661e0c596e23f1f3ea83f953e9a77f02
7
+ data.tar.gz: 248b20766df771c73189fd3eb61f218a5b016254a2271f965c4076cfde2bd9391eef4ab6afcc1ea5a5424a3ca86c4f15728aeb12bf415bb2b03721722828bced
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Lightstreamer Changelog
2
2
 
3
+ ### 0.3 — July 24, 2016
4
+
5
+ - Seamlessly rebind the stream connection when a `LOOP` message is received
6
+ - Correctly handle UTF-16 escape sequences in stream data, including UTF-16 surrogate pairs
7
+ - Switched to the `typhoeus` library for HTTP support
8
+ - Improved error handling on the stream thread
9
+ - Added `Lightstreamer::Session#connected?`
10
+
3
11
  ### 0.2 — July 23, 2016
4
12
 
5
13
  - Added complete test suite
data/README.md CHANGED
@@ -43,9 +43,9 @@ session = Lightstreamer::Session.new server_url: 'http://push.lightstreamer.com'
43
43
  # Connect the session
44
44
  session.connect
45
45
 
46
- # Create a new subscription that subscribes to five items and to four fields on each item
47
- subscription = Lightstreamer::Subscription.new items: %w(item1 item2 item3 item4 item5),
48
- fields: [:time, :stock_name, :bid, :ask],
46
+ # Create a new subscription that subscribes to thirty items and to four fields on each item
47
+ subscription = Lightstreamer::Subscription.new items: (1..30).map { |i| "item#{i}" },
48
+ fields: [:ask, :bid, :stock_name, :time],
49
49
  mode: :merge, adapter: 'QUOTE_ADAPTER'
50
50
 
51
51
  # Create a thread-safe queue
@@ -76,10 +76,10 @@ To print streaming data from the demo server run the following command:
76
76
 
77
77
  ```
78
78
  lightstreamer --server-url http://push.lightstreamer.com --adapter-set DEMO --adapter QUOTE_ADAPTER \
79
- --items item1 item2 item3 item4 item5 --fields time stock_name bid ask bid
79
+ --items item1 item2 item3 item4 item5 --fields ask bid stock_name time
80
80
  ```
81
81
 
82
- To see a full list of available options run the following command:
82
+ To see a full list of available options for the command-line client run the following command:
83
83
 
84
84
  ```
85
85
  lightstreamer help stream
data/lib/lightstreamer.rb CHANGED
@@ -1,5 +1,6 @@
1
- require 'rest-client'
2
1
  require 'thor'
2
+ require 'typhoeus'
3
+ require 'uri'
3
4
 
4
5
  require 'lightstreamer/control_connection'
5
6
  require 'lightstreamer/line_buffer'
@@ -7,7 +8,9 @@ require 'lightstreamer/protocol_error'
7
8
  require 'lightstreamer/request_error'
8
9
  require 'lightstreamer/session'
9
10
  require 'lightstreamer/stream_connection'
11
+ require 'lightstreamer/stream_connection_header'
10
12
  require 'lightstreamer/subscription'
13
+ require 'lightstreamer/utf16'
11
14
  require 'lightstreamer/version'
12
15
 
13
16
  require 'lightstreamer/cli/main'
@@ -31,16 +31,14 @@ module Lightstreamer
31
31
 
32
32
  private
33
33
 
34
- # Executes a POST request to the control address with the specified payload. Raises an error if the HTTP request
35
- # fails. Returns the response from the server split into individual lines.
34
+ # Executes a POST request to the control address with the specified payload. Raises {RequestError} if the HTTP
35
+ # request fails. Returns the response body split into individual lines.
36
36
  def execute_post_request(payload)
37
- response = RestClient::Request.execute method: :post, url: @control_url, payload: payload
37
+ response = Typhoeus.post @control_url, body: payload
38
+
39
+ raise RequestError.new(response.return_message, response.response_code) unless response.success?
38
40
 
39
41
  response.body.split("\n").map(&:strip)
40
- rescue RestClient::Exception => exception
41
- raise RequestError, exception.message
42
- rescue SocketError => socket_error
43
- raise RequestError, socket_error
44
42
  end
45
43
 
46
44
  # Constructs the payload for a Lightstreamer control request based on the given options hash. See {#execute} for
@@ -54,7 +52,7 @@ module Lightstreamer
54
52
 
55
53
  build_optional_payload_fields options, params
56
54
 
57
- URI.encode_www_form params
55
+ params
58
56
  end
59
57
 
60
58
  def build_optional_payload_fields(options, params)
@@ -36,20 +36,22 @@ module Lightstreamer
36
36
  def connect
37
37
  return if @stream_connection
38
38
 
39
- @stream_connection = StreamConnection.new self
40
-
41
- first_line = @stream_connection.read_line
39
+ create_stream_connection
40
+ create_control_connection
41
+ create_processing_thread
42
+ rescue
43
+ @stream_connection = nil
44
+ raise
45
+ end
42
46
 
43
- if first_line == 'OK'
44
- @session_id = read_session_id
45
- create_control_connection
46
- create_processing_thread
47
- elsif first_line == 'ERROR'
48
- handle_connection_error
49
- end
47
+ # Returns whether this session is currently connected and has an active stream connection.
48
+ #
49
+ # @return [Boolean]
50
+ def connected?
51
+ !@stream_connection.nil?
50
52
  end
51
53
 
52
- # Disconnects this session and shuts down its stream and processing threads.
54
+ # Disconnects this session and shuts down its stream connection and processing threads.
53
55
  def disconnect
54
56
  @stream_connection.disconnect if @stream_connection
55
57
 
@@ -102,25 +104,20 @@ module Lightstreamer
102
104
 
103
105
  private
104
106
 
105
- # Parses the next line of data from the stream connection as the session ID and returns it.
106
- def read_session_id
107
- @stream_connection.read_line.match(/^SessionId:(.*)$/).captures.first
107
+ def create_stream_connection
108
+ @stream_connection = StreamConnection.new self
109
+ @stream_connection.connect
108
110
  end
109
111
 
110
- # Attempts to parses the next line of data from the stream connection as a custom control address and then uses this
111
- # address to create the control connection. Note that the control address is optional and if it is absent then
112
- # {#server_url} will be used instead of a custom control address.
113
112
  def create_control_connection
114
- match = @stream_connection.read_line.match(/^ControlAddress:(.*)$/)
115
- control_address = (match && match.captures.first) || server_url
116
-
117
- # The rest of the contents in the header is ignored, so read up until the blank line that marks its ending
118
- loop { break if @stream_connection.read_line == '' }
113
+ control_address = @stream_connection.control_address || server_url
119
114
 
120
- # If the control URL doesn't have a schema then use the same schema as the server URL
121
- control_address = "#{URI(server_url).scheme}://#{control_address}" unless control_address.start_with? 'http'
115
+ # If the control address doesn't have a schema then use the same schema as the server URL
116
+ unless control_address.start_with? 'http'
117
+ control_address = "#{URI(server_url).scheme}://#{control_address}"
118
+ end
122
119
 
123
- @control_connection = ControlConnection.new @session_id, control_address
120
+ @control_connection = ControlConnection.new @stream_connection.session_id, control_address
124
121
  end
125
122
 
126
123
  # Starts the processing thread that reads and processes incoming data from the stream connection.
@@ -129,8 +126,14 @@ module Lightstreamer
129
126
  loop do
130
127
  line = @stream_connection.read_line
131
128
 
129
+ break if line.nil?
130
+
132
131
  process_stream_data line unless line.empty?
133
132
  end
133
+
134
+ # The stream connection has died, so exit the processing thread
135
+ warn "Lightstreamer: processing thread exiting, error: #{@stream_connection.error}"
136
+ @processing_thread = @stream_connection = @control_connection = nil
134
137
  end
135
138
  end
136
139
 
@@ -145,17 +148,5 @@ module Lightstreamer
145
148
 
146
149
  warn "Lightstreamer: unprocessed stream data '#{line}'" unless was_processed
147
150
  end
148
-
149
- # Handles a failure to establish a stream connection by reading off the error code and error message then raising
150
- # a {ProtocolError}.
151
- def handle_connection_error
152
- error_code = @stream_connection.read_line
153
- error_message = @stream_connection.read_line
154
-
155
- @stream_connection = nil
156
- @control_connection = nil
157
-
158
- raise ProtocolError.new(error_message, error_code)
159
- end
160
151
  end
161
152
  end
@@ -5,6 +5,16 @@ module Lightstreamer
5
5
  # @return [Thread] The thread used to process incoming streaming data.
6
6
  attr_reader :thread
7
7
 
8
+ # @return [String] The session ID returned from the server when this stream connection was initiated.
9
+ attr_reader :session_id
10
+
11
+ # @return [String] The control address returned from the server when this stream connection was initiated.
12
+ attr_reader :control_address
13
+
14
+ # @return [ProtocolError, RequestError, String] If an error occurs on the stream thread that causes this stream
15
+ # to disconnect then the exception or error details will be stored in this attribute.
16
+ attr_reader :error
17
+
8
18
  # Establishes a new stream connection using the authentication details from the passed session.
9
19
  #
10
20
  # @param [Session] session The session to create a stream connection for.
@@ -12,73 +22,128 @@ module Lightstreamer
12
22
  @session = session
13
23
  @queue = Queue.new
14
24
 
15
- create_stream
25
+ @stream_create_url = URI.join(session.server_url, '/lightstreamer/create_session.txt').to_s
26
+ @stream_bind_url = URI.join(session.server_url, '/lightstreamer/bind_session.txt').to_s
27
+ end
28
+
29
+ # Establishes a new stream connection using the authentication details from the session that was passed to
30
+ # {#initialize}. Raises {ProtocolError} or {RequestError} on failure.
31
+ def connect
32
+ return if @thread
33
+ @session_id = @error = nil
34
+
16
35
  create_stream_thread
36
+
37
+ # Wait until the connection result is known
38
+ until @session_id || @error
39
+ end
40
+
41
+ raise @error if @error
42
+ end
43
+
44
+ # Returns whether or not this stream connection is connected.
45
+ #
46
+ # @return [Boolean]
47
+ def connected?
48
+ !@thread.nil?
17
49
  end
18
50
 
19
51
  # Disconnects this stream connection by shutting down the streaming thread.
20
52
  def disconnect
21
- return unless @thread
53
+ if @thread
54
+ Thread.kill @thread
55
+ @thread.join
56
+ end
22
57
 
23
- Thread.kill @thread
24
- @thread.join
58
+ @session_id = @control_address = @error = @thread = nil
25
59
  end
26
60
 
27
- # Reads the next line of streaming data. This method blocks the calling thread until a line of data is available.
61
+ # Reads the next line of streaming data. If the streaming thread is alive then this method blocks the calling thread
62
+ # until a line of data is available. If the streaming thread is not active then any unconsumed lines will be
63
+ # returned and after that the return value will be `nil`.
64
+ #
65
+ # @return [String, nil]
28
66
  def read_line
67
+ return nil if @queue.empty? && @thread.nil?
68
+
29
69
  @queue.pop
30
70
  end
31
71
 
32
72
  private
33
73
 
34
- def create_stream
35
- @stream = Net::HTTP.new stream_uri.host, stream_uri.port
36
- @stream.use_ssl = true if stream_uri.port == 443
37
- end
38
-
39
74
  def create_stream_thread
40
75
  @thread = Thread.new do
41
76
  begin
42
- connect_stream_and_queue_data
77
+ stream_thread_main
43
78
  rescue StandardError => error
44
- warn "Lightstreamer: exception in stream thread: #{error}"
45
- exit 1
79
+ @error = error
46
80
  end
81
+
82
+ @thread = nil
47
83
  end
48
84
  end
49
85
 
50
- def initiate_stream_post_request
51
- Net::HTTP::Post.new(stream_uri.path).tap do |request|
52
- request.body = stream_create_parameters
86
+ def stream_thread_main
87
+ connect_stream_and_process_data stream_create_post_request
88
+
89
+ while @loop
90
+ @loop = false
91
+ connect_stream_and_process_data stream_bind_post_request
53
92
  end
54
93
  end
55
94
 
56
- def connect_stream_and_queue_data
57
- @stream.request initiate_stream_post_request do |response|
58
- buffer = LineBuffer.new
59
- response.read_body do |data|
60
- buffer.process data do |line|
61
- @queue.push line unless ignore_line? line
62
- end
63
- end
95
+ def stream_create_post_request
96
+ params = { LS_op2: 'create', LS_cid: 'mgQkwtwdysogQz2BJ4Ji kOj2Bg', LS_user: @session.username,
97
+ LS_password: @session.password }
98
+
99
+ params[:LS_adapter_set] = @session.adapter_set if @session.adapter_set
100
+
101
+ Typhoeus::Request.new @stream_create_url, method: :post, params: params
102
+ end
103
+
104
+ def stream_bind_post_request
105
+ Typhoeus::Request.new @stream_bind_url, method: :post, params: { LS_session: @session_id }
106
+ end
107
+
108
+ def connect_stream_and_process_data(request)
109
+ @header = StreamConnectionHeader.new
110
+
111
+ buffer = LineBuffer.new
112
+ request.on_body do |data|
113
+ buffer.process data, &method(:process_stream_line)
114
+ end
115
+
116
+ request.on_complete do |response|
117
+ @error = RequestError.new(response.return_message, response.response_code) unless response.success?
64
118
  end
119
+
120
+ request.run
65
121
  end
66
122
 
67
- def stream_uri
68
- URI.join @session.server_url, '/lightstreamer/create_session.txt'
123
+ def process_stream_line(line)
124
+ if @header
125
+ process_header_line line
126
+ else
127
+ process_body_line line
128
+ end
69
129
  end
70
130
 
71
- def stream_create_parameters
72
- params = {
73
- LS_op2: 'create',
74
- LS_cid: 'mgQkwtwdysogQz2BJ4Ji kOj2Bg',
75
- LS_user: @session.username,
76
- LS_password: @session.password
77
- }
131
+ def process_header_line(line)
132
+ return if @header.process_header_line line
78
133
 
79
- params[:LS_adapter_set] = @session.adapter_set if @session.adapter_set
134
+ @control_address = @header['ControlAddress']
135
+ @session_id = @header['SessionId']
136
+ @error = @header.error
137
+
138
+ @header = nil
139
+ end
80
140
 
81
- URI.encode_www_form params
141
+ def process_body_line(line)
142
+ if line == 'LOOP'
143
+ @loop = true
144
+ elsif !ignore_line?(line)
145
+ @queue.push line
146
+ end
82
147
  end
83
148
 
84
149
  def ignore_line?(line)
@@ -0,0 +1,60 @@
1
+ module Lightstreamer
2
+ # Helper class that processes the contents of the header returned by the server when a new stream connection is
3
+ # created or an existing session is bound to.
4
+ class StreamConnectionHeader
5
+ # @return [ProtocolError, RequestError] If there was an error in the header then this value will be set to the
6
+ # error instance that should be raised in response.
7
+ attr_reader :error
8
+
9
+ def initialize
10
+ @data = {}
11
+ @lines = []
12
+ end
13
+
14
+ # Processes a single line of header information. The return value indicates whether further data is required in
15
+ # order to complete the header.
16
+ #
17
+ # @param [String] line The line of header data to process.
18
+ #
19
+ # @return [Boolean] Whether the header is still incomplete and requires further data.
20
+ def process_header_line(line)
21
+ @lines << line
22
+
23
+ unless %w(OK ERROR).include? @lines.first
24
+ @error = RequestError.new line
25
+ return false
26
+ end
27
+
28
+ return true if @lines.first == 'OK' && !line.empty?
29
+ return true if @lines.first == 'ERROR' && @lines.size < 3
30
+
31
+ parse_header
32
+
33
+ false
34
+ end
35
+
36
+ # Returns the value for the item with the specified name in this header, or `nil` if no item with the specified name
37
+ # was part of this header
38
+ #
39
+ # @param [String] item_name The name of the item to return the header value for.
40
+ #
41
+ # @return [String, nil] The value of the item as specified in this header, or `nil` if the item name was not
42
+ # specified in this header.
43
+ def [](item_name)
44
+ @data[item_name]
45
+ end
46
+
47
+ private
48
+
49
+ def parse_header
50
+ if @lines.first == 'OK'
51
+ @lines[1..-1].each do |line|
52
+ match = line.match(/^([^:]*):(.*)$/)
53
+ @data[match.captures[0]] = match.captures[1] if match
54
+ end
55
+ elsif @lines.first == 'ERROR'
56
+ @error = ProtocolError.new @lines[2], @lines[1]
57
+ end
58
+ end
59
+ end
60
+ end
@@ -134,12 +134,8 @@ module Lightstreamer
134
134
 
135
135
  private
136
136
 
137
- # Attempts to parse an line of stream data.
138
- #
139
- # @param [String] line The line of stream data to parse.
140
- #
141
- # @return [Array] The first value is the item index, and the second is a hash of the values contained in the stream
142
- # data. Will be nil if the stream data was not able to be parsed.
137
+ # Attempts to parse a line of stream data. If parsing is successful then the first return value is the item index,
138
+ # and the second is a hash of the values contained in the stream data.
143
139
  def parse_stream_data(line)
144
140
  match = line.match stream_data_regex
145
141
  return unless match
@@ -152,18 +148,12 @@ module Lightstreamer
152
148
 
153
149
  # Returns the regular expression that will match a single line of data in the incoming stream that is relevant to
154
150
  # this subscription. The ID at the beginning must match, as well as the number of fields.
155
- #
156
- # @return [Regexp]
157
151
  def stream_data_regex
158
152
  Regexp.new "^#{id},(\\d+)#{'\|(.*)' * fields.size}"
159
153
  end
160
154
 
161
155
  # Parses an array of values from an incoming line of stream data into a hash where the keys are the field names
162
156
  # defined for this subscription.
163
- #
164
- # @param [String] values The raw values from the incoming stream data.
165
- #
166
- # @return [Hash] The parsed values as a hash where the keys are the field names of this subscription.
167
157
  def parse_values(values)
168
158
  hash = {}
169
159
 
@@ -183,10 +173,7 @@ module Lightstreamer
183
173
 
184
174
  value = value[1..-1] if value =~ /^(\$|#)/
185
175
 
186
- # TODO: parse any non-ASCII characters which are specified as UTF-16 escape sequences in the form '\uXXXX' or
187
- # '\uXXXX\uYYYY'. They need to be transformed into native Unicode characters.
188
-
189
- value
176
+ UTF16.decode_escape_sequences value
190
177
  end
191
178
 
192
179
  # Invokes all of this subscription's data callbacks with the specified arguments. Any exceptions that occur in a
@@ -0,0 +1,31 @@
1
+ module Lightstreamer
2
+ # This module supports the decoding of UTF-16 escape sequences
3
+ module UTF16
4
+ module_function
5
+
6
+ # Decodes any UTF-16 escape sequences in the form '\uXXXX' into a new string. Invalid escape sequences are removed.
7
+ def decode_escape_sequences(string)
8
+ string = decode_surrogate_pairs_escape_sequences string
9
+
10
+ # Match all remaining escape sequences
11
+ string.gsub(/\\u[A-F\d]{4}/i) do |escape_sequence|
12
+ codepoint = escape_sequence[2..-1].hex
13
+
14
+ # Codepoints greater than 0xD7FF are invalid
15
+ codepoint < 0xD800 ? [codepoint].pack('U') : ''
16
+ end
17
+ end
18
+
19
+ # Converts any UTF-16 surrogate pairs escape sequences in the form '\uXXXX\uYYYY' into UTF-8.
20
+ def decode_surrogate_pairs_escape_sequences(string)
21
+ string.gsub(/\\uD[89AB][A-F\d]{2}\\uD[C-F][A-F\d]{2}/i) do |escape_sequence|
22
+ high_surrogate = escape_sequence[2...6].hex
23
+ low_surrogate = escape_sequence[8...12].hex
24
+
25
+ codepoint = 0x10000 + ((high_surrogate - 0xD800) << 10) + (low_surrogate - 0xDC00)
26
+
27
+ [codepoint].pack 'U'
28
+ end
29
+ end
30
+ end
31
+ end
@@ -1,4 +1,4 @@
1
1
  module Lightstreamer
2
2
  # The version of this gem.
3
- VERSION = '0.2'.freeze
3
+ VERSION = '0.3'.freeze
4
4
  end
metadata CHANGED
@@ -1,43 +1,43 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lightstreamer
3
3
  version: !ruby/object:Gem::Version
4
- version: '0.2'
4
+ version: '0.3'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Richard Viney
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2016-07-23 00:00:00.000000000 Z
11
+ date: 2016-07-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: rest-client
14
+ name: thor
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '2.0'
19
+ version: '0.19'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '2.0'
26
+ version: '0.19'
27
27
  - !ruby/object:Gem::Dependency
28
- name: thor
28
+ name: typhoeus
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
31
  - - "~>"
32
32
  - !ruby/object:Gem::Version
33
- version: '0.19'
33
+ version: '1.1'
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
- version: '0.19'
40
+ version: '1.1'
41
41
  - !ruby/object:Gem::Dependency
42
42
  name: activesupport
43
43
  requirement: !ruby/object:Gem::Requirement
@@ -184,7 +184,9 @@ files:
184
184
  - lib/lightstreamer/request_error.rb
185
185
  - lib/lightstreamer/session.rb
186
186
  - lib/lightstreamer/stream_connection.rb
187
+ - lib/lightstreamer/stream_connection_header.rb
187
188
  - lib/lightstreamer/subscription.rb
189
+ - lib/lightstreamer/utf16.rb
188
190
  - lib/lightstreamer/version.rb
189
191
  homepage: https://github.com/rviney/lightstreamer
190
192
  licenses: