lightstreamer 0.2 → 0.3

Sign up to get free protection for your applications and to get access to all the features.
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: