satori-rtm-sdk 0.0.1.rc1

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.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: e4b294a724bf1777e77a6694a414384215ea18d2
4
+ data.tar.gz: d505330ffc42ccd8d329b12b22405c1495792513
5
+ SHA512:
6
+ metadata.gz: bfe76c7b4fc24dc0e6620321f53ed5c2b968058da80276bc8f6227d6ec9bc5702e4496ae7f31a4f6ac71e111223404e379103341ebbe149bb4c9eb5140060cce
7
+ data.tar.gz: 5d54ce8c2363f343960a86ad03223fceed82e65ee5fc5d04a851d6ebd7bce83b3d9662d57c0b19a7ce1a599bf8a6e68a00e0b6350cc21fe4dd84180b3bc3c7a1
@@ -0,0 +1,3 @@
1
+ v0.0.1 (?)
2
+ -------------------
3
+ * Initial release
@@ -0,0 +1,97 @@
1
+ # Ruby SDK for Satori RTM
2
+
3
+ RTM is the realtime messaging service at the core of the [Satori](https://www.satori.com).
4
+
5
+ Ruby SDK makes it more convenient to use Satori RTM from [Ruby programming language](https://www.ruby-lang.org).
6
+
7
+ ## Installation
8
+
9
+ Ruby SDK works on Ruby >= 2.0 and JRuby.
10
+
11
+ Install it with [RubyGems](https://rubygems.org/)
12
+
13
+ gem install satori-rtm-sdk
14
+
15
+ or add this to your Gemfile if you use [Bundler](http://gembundler.com/):
16
+
17
+ gem "satori-rtm-sdk"
18
+
19
+ ## Documentation
20
+
21
+ * [Satori Ruby SDK API](...)
22
+ * [RTM API](https://www.satori.com/docs/using-satori/rtm-api)
23
+
24
+ ## Getting started
25
+
26
+ Here's an example how to use Satori RTM SDK to write publish / subscribe logic:
27
+
28
+ ```ruby
29
+ require 'satori-rtm-sdk'
30
+
31
+ endpoint = 'YOUR_ENDPOINT'
32
+ appkey = 'YOUR_APPKEY'
33
+
34
+ client = Satori::RTM::Client.new(endpoint, appkey)
35
+
36
+ client.connect
37
+
38
+ client.subscribe 'animals' do |_ctx, event|
39
+ case event.type
40
+ when :subscribed
41
+ puts "Subscribed to the channel: #{event.data[:subscription_id]}"
42
+ when :data
43
+ event.data[:messages].each { |msg| puts "Animal is received #{msg}" }
44
+ when :error
45
+ puts "Subscription error: #{event.data[:error]} -- #{event.data[:reason]}"
46
+ end
47
+ end
48
+
49
+ loop do
50
+ client.publish 'animals', who: 'zebra', where: [34.13, -118.32]
51
+ client.sock_read_repeatedly duration_in_secs: 2
52
+ end
53
+ ```
54
+
55
+ ## EventMachine
56
+
57
+ Ruby SDK for Satori RTM doesn't lock you into using threading or event loop frameworks, but it's ready to be used with any of those.
58
+
59
+ The example of using Ruby SDK with EventMachine can be found [here](...)
60
+
61
+ ## Logging
62
+
63
+ You can enable dumping of all PDUs either from your code
64
+
65
+ ```ruby
66
+ Satori::RTM::Logger.use_std_logger(::Logger::DEBUG)
67
+ ```
68
+
69
+ or by setting `DEBUG_SATORI_SDK` environment variable prior to running your application
70
+
71
+ ```ruby
72
+ $ DEBUG_SATORI_SDK=true ruby myapp.rb
73
+ ```
74
+
75
+ ## Testing Your Changes
76
+
77
+ Tests require an active RTM to be available. The tests require `credentials.json` to be populated with the RTM properties.
78
+
79
+ The `credentials.json` file must include the following key-value pairs:
80
+
81
+ ```
82
+ {
83
+ "endpoint": "YOUR_ENDPOINT",
84
+ "appkey": "YOUR_APPKEY",
85
+ "auth_role_name": "YOUR_ROLE",
86
+ "auth_role_secret_key": "YOUR_SECRET",
87
+ "auth_restricted_channel": "YOUR_RESTRICTED_CHANNEL"
88
+ }
89
+ ```
90
+
91
+ * `endpoint` is your customer-specific endpoint for RTM access.
92
+ * `appkey` is your application key.
93
+ * `auth_role_name` is a role name that permits to publish / subscribe to `auth_restricted_channel`. Must be not `default`.
94
+ * `auth_role_secret_key` is a secret key for `auth_role_name`.
95
+ * `auth_restricted_channel` is a channel with subscribe and publish access for `auth_role_name` role only.
96
+
97
+ After setting up `credentials.json`, just type `rspec spec` at the command line.
@@ -0,0 +1,13 @@
1
+ require 'satori-rtm-sdk/codec'
2
+ require 'satori-rtm-sdk/event_emitter'
3
+ require 'satori-rtm-sdk/logger'
4
+ require 'satori-rtm-sdk/websocket'
5
+ require 'satori-rtm-sdk/model'
6
+ require 'satori-rtm-sdk/client'
7
+ require 'satori-rtm-sdk/version'
8
+
9
+ module Satori
10
+ # Satori RTM classes.
11
+ module RTM
12
+ end
13
+ end
@@ -0,0 +1,477 @@
1
+ require 'openssl'
2
+ require 'uri'
3
+
4
+ module Satori
5
+ module RTM
6
+ # The client that an application uses for accessing RTM.
7
+ #
8
+ # @!attribute [r] transport
9
+ # @return [WebSocket] the WebSocket connection
10
+ class Client
11
+ include EventEmitter
12
+ include Logger
13
+
14
+ attr_reader :transport
15
+ private :on, :fire
16
+
17
+ # Returns a new instance of Client.
18
+ #
19
+ # @param endpoint [String] RTM endpoint
20
+ # @param appkey [String] appkey used to access RTM
21
+ # @param opts [Hash] options to create a client with.
22
+ # @option opts [WebSocket] :transport WebSocket connection implementation to use
23
+ def initialize(endpoint, appkey, opts = {})
24
+ @waiters = {}
25
+ @subscriptions = {}
26
+ @url = URI.join(endpoint, 'v2?appkey=' + appkey).to_s
27
+ @id = 0
28
+ @encoder = JsonCodec.new
29
+ @transport = init_transport(opts)
30
+ @state = :init
31
+ end
32
+
33
+ # @!group I/O
34
+
35
+ # Connects to Satori RTM.
36
+ #
37
+ # @raise [ConnectionError] network error occurred when connecting
38
+ # @return [void]
39
+ def connect
40
+ raise ConnectionError, "Client is a single-use object. You can't connect twice." if @state != :init
41
+ logger.info "connecting to #{@url}"
42
+ @transport.connect(@url)
43
+ rescue => e
44
+ # generate websocket close event
45
+ on_websocket_close(1006, "Connect exception: #{e.message}")
46
+ raise e
47
+ end
48
+
49
+ # Defines a callback to call when the client is successfully connected to Satori RTM.
50
+ #
51
+ # @yield Calls when client is connected
52
+ # @return [void]
53
+ def onopen(&fn)
54
+ on :open, &fn
55
+ end
56
+
57
+ # Defines a callback to call when the client is disconnected or not able to connect to Satori RTM.
58
+ #
59
+ # If a client is not able to connect, a +onclose+ yields too with a reason.
60
+ #
61
+ # @yield Calls when client is disconnected or not able to connect
62
+ # @yieldparam close_event [CloseEvent] reason why client was closed
63
+ # @return [void]
64
+ def onclose(&fn)
65
+ on :close, &fn
66
+ end
67
+
68
+ # Returns +true+ if the client is connected.
69
+ #
70
+ # @return [Boolean] +true+ if connected, +false+ otherwise
71
+ def connected?
72
+ @state == :open
73
+ end
74
+
75
+ # Closes gracefully the connection to Satori RTM.
76
+ # @return [void]
77
+ def close
78
+ @transport.close
79
+ end
80
+
81
+ # Reads from an WebSocket with an optional timeout.
82
+ #
83
+ # If timeout is greater than zero, it specifies a maximum interval (in seconds)
84
+ # to wait for any incoming WebSocket frames. If timeout is zero,
85
+ # then the method returns without blocking. If the timeout is less
86
+ # than zero, the method blocks indefinitely.
87
+ #
88
+ # @param opts [Hash] additional options
89
+ # @option opts [Integer] :timeout_in_secs (-1) timeout for a read operation
90
+ #
91
+ # @raise [ConnectionError] network error occurred when reading data
92
+ # @return [:ok] read successfully reads data from WebSocket
93
+ # @return [:timeout] a blocking operation times out
94
+ def sock_read(opts = {})
95
+ timeout_in_secs = opts.fetch(:timeout_in_secs, -1)
96
+ if timeout_in_secs >= 0
97
+ @transport.read_with_timeout(timeout_in_secs)
98
+ else
99
+ @transport.read
100
+ end
101
+ end
102
+
103
+ # Reads from an WebSocket in a non-blocking mode.
104
+ #
105
+ # @raise [ConnectionError] network error occurred when reading data
106
+ # @return [:ok] read successfully reads data from WebSocket
107
+ # @return [:would_block] read buffer is empty
108
+ def sock_read_nonblock
109
+ @transport.read_nonblock
110
+ end
111
+
112
+ # Reads repeatedly from an WebSocket.
113
+ #
114
+ # This method repeatedly reads from an WebSocket during a specified
115
+ # time (in seconds). If duration time is greater then zero, then the
116
+ # method blocks for the duration time and reads repeatedly all
117
+ # incoming WebSocket frames. If duration time is less than zero, the
118
+ # method blocks indefinitely.
119
+ #
120
+ # @param opts [Hash] additional options
121
+ # @option opts [Integer] :duration_in_secs (-1) duration interval
122
+ #
123
+ # @raise [ConnectionError] network error occurred when reading data
124
+ # @return [void]
125
+ def sock_read_repeatedly(opts = {})
126
+ duration_in_secs = opts.fetch(:duration_in_secs, -1)
127
+ start = Time.now
128
+ loop do
129
+ diff = (Time.now - start)
130
+ break if (duration_in_secs >= 0) && (duration_in_secs <= diff)
131
+ @transport.read_with_timeout(duration_in_secs - diff)
132
+ end
133
+ end
134
+
135
+ # Wait for all RTM replies for all pending requests.
136
+ #
137
+ # This method blocks until all RTM replies are received for all
138
+ # pending requests.
139
+ #
140
+ # If timeout is greater than zero, it specifies a maximum interval (in seconds)
141
+ # to wait for any incoming WebSocket frames. If the timeout is less
142
+ # than zero, the method blocks indefinitely.
143
+ #
144
+ # @note if user's callback for a reply sends new RTM request
145
+ # then this method waits it too.
146
+ #
147
+ # @param opts [Hash] additional options
148
+ # @option opts [Integer] :timeout_in_secs (-1) timeout for an operation
149
+ #
150
+ # @raise [ConnectionError] network error occurred when reading data
151
+ #
152
+ # @return [:ok] all replies are received
153
+ # @return [:timeout] a blocking operation times out
154
+ def wait_all_replies(opts = {})
155
+ timeout_in_secs = opts.fetch(:timeout_in_secs, -1)
156
+ start = Time.now
157
+ rc = :ok
158
+ loop do
159
+ break if @waiters.empty?
160
+
161
+ if timeout_in_secs >= 0
162
+ diff = (Time.now - start)
163
+ if timeout_in_secs <= diff
164
+ rc = :timeout
165
+ break
166
+ end
167
+ @transport.read_with_timeout(timeout_in_secs - diff)
168
+ else
169
+ @transport.read_with_timeout(1)
170
+ end
171
+ end
172
+ rc
173
+ end
174
+
175
+ # @!endgroup
176
+
177
+ # @!group Satori RTM operations
178
+
179
+ # Publishes a message to a channel.
180
+ #
181
+ # @param channel [String] name of the channel
182
+ # @param message [Object] message to publish
183
+ # @yield Callback for an RTM reply. If the block is not given, then
184
+ # no reply will be sent to a client, regardless of the outcome
185
+ # @yieldparam reply [BaseReply] RTM reply for delete request
186
+ # @return [void]
187
+ def publish(channel, message, &fn)
188
+ publish_opts = {
189
+ channel: channel,
190
+ message: message
191
+ }
192
+ send_r('rtm/publish', publish_opts, &fn)
193
+ end
194
+
195
+ # Reads a message in a channel.
196
+ #
197
+ # RTM returns the message at the position specified in the request.
198
+ # If there is no position specified, RTM defaults to the position of
199
+ # the latest message in the channel. A +null+ message in the reply
200
+ # PDU means that there were no messages at that position.
201
+ #
202
+ # @param channel [String] name of the channel
203
+ # @param opts [Hash] additional options for +rtm/read+ request
204
+ # @yield Callback for an RTM reply. If the block is not given, then
205
+ # no reply will be sent to a client, regardless of the outcome
206
+ # @yieldparam reply [BaseReply] RTM reply for delete request
207
+ # @return [void]
208
+ def read(channel, opts = {}, &fn)
209
+ read_opts = opts.merge channel: channel
210
+ send_r('rtm/read', read_opts, &fn)
211
+ end
212
+
213
+ # Writes the value of the specified key from the key-value store.
214
+ #
215
+ # Key is represented by a channel. In current RTM implementation
216
+ # write operation is the same as publish operation.
217
+ #
218
+ # @param channel [String] name of the channel
219
+ # @param message [Object] message to write
220
+ # @yield Callback for an RTM reply. If the block is not given, then
221
+ # no reply will be sent to a client, regardless of the outcome
222
+ # @yieldparam reply [BaseReply] RTM reply for delete request
223
+ # @return [void]
224
+ def write(channel, message, &fn)
225
+ write_opts = {
226
+ channel: channel,
227
+ message: message
228
+ }
229
+ send_r('rtm/write', write_opts, &fn)
230
+ end
231
+
232
+ # Deletes the value of the specified key from the key-value store.
233
+ #
234
+ # Key is represented by a channel, and only the last message in the
235
+ # channel is relevant (represents the value). Hence, publishing a +null+
236
+ # value, serves as deletion of the the previous value (if any). Delete request
237
+ # is the same as publishing or writing a null value to the channel.
238
+ #
239
+ # @param channel [String] name of the channel
240
+ # @yield Callback for an RTM reply. If the block is not given, then
241
+ # no reply will be sent to a client, regardless of the outcome
242
+ # @yieldparam reply [BaseReply] RTM reply for delete request
243
+ # @return [void]
244
+ def delete(channel, &fn)
245
+ delete_opts = { channel: channel }
246
+ send_r('rtm/delete', delete_opts, &fn)
247
+ end
248
+
249
+ # Subscribes to a channel
250
+ #
251
+ # When you create a subscription, you can specify additional subscription options (e.g. history or view).
252
+ # Full list of subscription option you could find in RTM API specification.
253
+ #
254
+ # Satori SDK informs an user about any subscription state changes by calling block with proper event.
255
+ #
256
+ # @see SubscriptionEvent Information about subscription events
257
+ #
258
+ # @param sid [String] subscription id
259
+ # @param opts [Hash] additional options for +rtm/subscribe+ request
260
+ # @yield RTM subscription callback
261
+ # @yieldparam ctx [SubscriptionContext] current subscription context
262
+ # @yieldparam event [SubscriptionEvent] subscription event
263
+ # @return [void]
264
+ #
265
+ # @example
266
+ # client.subscribe 'animals' do |_ctx, event|
267
+ # case event.type
268
+ # when :subscribed
269
+ # puts "Subscribed to the channel: #{event.data[:subscription_id]}"
270
+ # when :data
271
+ # event.data[:messages].each { |msg| puts "Message is received #{msg}" }
272
+ # when :error
273
+ # puts "Subscription error: #{event.data[:error]} -- #{event.data[:reason]}"
274
+ # end
275
+ # end
276
+ def subscribe(sid, opts = {}, &fn)
277
+ request_opts = opts.merge subscription_id: sid
278
+ request_opts[:channel] = sid unless %i[filter view].any? { |k| opts.key?(k) }
279
+
280
+ context = SubscriptionContext.new(sid, opts, fn)
281
+
282
+ init_reply = SubscriptionEvent.new(:init, nil)
283
+ context.fn.call(context, init_reply)
284
+
285
+ send('rtm/subscribe', request_opts) do |status, data|
286
+ reply = context.handle_data(status, data)
287
+
288
+ if @subscriptions.key?(sid) && @subscriptions[sid] != context
289
+ prev_sub = @subscriptions[sid]
290
+ prev_sub.mark_as_resubscribed
291
+ end
292
+
293
+ @subscriptions[sid] = context if reply.type == :subscribed
294
+
295
+ context.fn.call(context, reply)
296
+ end
297
+ end
298
+
299
+ # Unsubscribes the subscription with the specific +subscription_id+
300
+ #
301
+ # @param sid [String] subscription id
302
+ # @yield Callback for an RTM reply
303
+ # @yieldparam reply [BaseReply] RTM reply for authenticate request
304
+ # @return [void]
305
+ def unsubscribe(sid)
306
+ request_opts = { subscription_id: sid }
307
+ send('rtm/unsubscribe', request_opts) do |status, data|
308
+ context = @subscriptions.delete(sid)
309
+ if context
310
+ reply = context.handle_data(status, data)
311
+ context.fn.call(context, reply)
312
+ end
313
+ # pass base reply to unsubscribe block
314
+ yield(BaseReply.new(status, data)) if block_given?
315
+ end
316
+ end
317
+
318
+ # Authenticates a user with specific role and secret.
319
+ #
320
+ # Authentication is based on the +HMAC+ algorithm with +MD5+ hashing routine:
321
+ # * The SDK obtains a nonce from the RTM in a handshake request
322
+ # * The SDK then sends an authorization request with its role secret
323
+ # key hashed with the received nonce
324
+ #
325
+ # If authentication is failed then reason is passed to the yield block. In
326
+ # case of success the +rtm/authenticate+ reply is passed to the yield block.
327
+ #
328
+ # Use Dev Portal to obtain the role and secret key for your application.
329
+ #
330
+ # @param role [String] role name
331
+ # @param secret [String] role secret
332
+ # @yield Callback for an RTM reply
333
+ # @yieldparam reply [BaseReply] RTM reply for authenticate request
334
+ # @return [void]
335
+ #
336
+ # @example
337
+ # client.authenticate role, role_secret do |reply|
338
+ # raise "Failed to authenticate: #{reply.data[:error]} -- #{reply.data[:reason]}" unless reply.success?
339
+ # end
340
+ # client.wait_all_replies
341
+ def authenticate(role, secret)
342
+ handshake_opts = {
343
+ method: 'role_secret',
344
+ data: { role: role }
345
+ }
346
+ send_r('auth/handshake', handshake_opts) do |reply|
347
+ if reply.success?
348
+ hash = hmac_md5(reply.data[:data][:nonce], secret)
349
+ authenticate_opts = {
350
+ method: 'role_secret',
351
+ credentials: { hash: hash }
352
+ }
353
+ send_r('auth/authenticate', authenticate_opts) do |auth_reply|
354
+ yield(auth_reply)
355
+ end
356
+ else
357
+ yield(reply)
358
+ end
359
+ end
360
+ end
361
+
362
+ # @!endgroup
363
+
364
+ private
365
+
366
+ def pdu_to_reply_adapter(fn)
367
+ return if fn.nil?
368
+
369
+ proc do |status, data|
370
+ reply = BaseReply.new(status, data)
371
+ fn.call(reply)
372
+ end
373
+ end
374
+
375
+ def send_r(action, body, &fn)
376
+ send(action, body, &pdu_to_reply_adapter(fn))
377
+ end
378
+
379
+ def send(action, body, &block)
380
+ pdu = { action: action, body: body }
381
+ if block_given?
382
+ pdu[:id] = gen_next_id
383
+ @waiters[pdu[:id]] = block
384
+ end
385
+ logger.debug("-> #{pdu}")
386
+ data = @encoder.encode(pdu)
387
+ @transport.send(data, type: :text)
388
+ end
389
+
390
+ def gen_next_id
391
+ @id += 1
392
+ end
393
+
394
+ def on_websocket_open
395
+ logger.info('connection is opened')
396
+ @state = :open
397
+ fire(:open)
398
+ end
399
+
400
+ def on_websocket_close(code, reason)
401
+ return if @state == :close
402
+ @state = :close
403
+ is_normal = (code == 1000)
404
+ if is_normal
405
+ logger.info('connection is closed normally')
406
+ else
407
+ logger.warn("connection is closed with code: '#{code}' -- '#{reason}'")
408
+ end
409
+
410
+ pass_disconnect_to_all_callbacks
411
+
412
+ @waiters = {}
413
+ @subscriptions = {}
414
+
415
+ fire(:close, CloseEvent.new(code, reason))
416
+ end
417
+
418
+ def pass_disconnect_to_all_callbacks
419
+ err = { error: 'disconnect', reason: 'Connection is closed' }
420
+
421
+ @waiters.sort_by(&:first).map do |_, fn|
422
+ safe_call(fn, :disconnect, err)
423
+ end
424
+ @subscriptions.map do |_, context|
425
+ reply = context.handle_data(:disconnect, err)
426
+ safe_call(context.fn, context, reply)
427
+ end
428
+ end
429
+
430
+ def on_websocket_message(data, _type)
431
+ pdu = @encoder.decode(data)
432
+ logger.debug("<- #{pdu}")
433
+ id = pdu[:id]
434
+ if id.nil?
435
+ on_unsolicited_pdu(pdu)
436
+ else
437
+ fn = @waiters.delete(id)
438
+ fn.call(:pdu, pdu) unless fn.nil?
439
+ end
440
+ end
441
+
442
+ def on_unsolicited_pdu(pdu)
443
+ if pdu[:action] == '/error'
444
+ reason = "Unclassified RTM error is received: #{pdu[:body][:error]} -- #{pdu[:body][:reason]}"
445
+ @transport.close 1008, reason
446
+ elsif pdu[:action].start_with? 'rtm/subscription'
447
+ sid = pdu[:body][:subscription_id]
448
+ context = @subscriptions[sid]
449
+ if context
450
+ reply = context.handle_data(:pdu, pdu)
451
+ context.fn.call(context, reply)
452
+ end
453
+ end
454
+ end
455
+
456
+ def hmac_md5(nonce, secret)
457
+ algorithm = OpenSSL::Digest.new('md5')
458
+ digest = OpenSSL::HMAC.digest(algorithm, secret, nonce)
459
+ Base64.encode64(digest).chomp
460
+ end
461
+
462
+ def init_transport(opts)
463
+ transport = opts[:transport] || WebSocket.new
464
+ transport.on(:open, &method(:on_websocket_open))
465
+ transport.on(:message, &method(:on_websocket_message))
466
+ transport.on(:close, &method(:on_websocket_close))
467
+ transport
468
+ end
469
+
470
+ def safe_call(fn, *args)
471
+ fn.call(*args)
472
+ rescue => e
473
+ logger.error(e)
474
+ end
475
+ end
476
+ end
477
+ end
@@ -0,0 +1,17 @@
1
+ require 'json'
2
+
3
+ module Satori
4
+ module RTM
5
+ # JSON message encoder / decoder.
6
+ # @!visibility private
7
+ class JsonCodec
8
+ def encode(pdu)
9
+ JSON.generate(pdu)
10
+ end
11
+
12
+ def decode(data)
13
+ JSON.parse(data, symbolize_names: true)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,39 @@
1
+ module Satori
2
+ module RTM
3
+ # Event emitter pattern implementation.
4
+ # @!visibility private
5
+ module EventEmitter
6
+ def self.included(klass)
7
+ klass.__send__ :include, InstanceMethods
8
+ end
9
+
10
+ # @!visibility private
11
+ module InstanceMethods
12
+ def __handlers
13
+ @__handlers ||= {}
14
+ end
15
+
16
+ def on(name, &fn)
17
+ get_handler(name) << fn
18
+ fn
19
+ end
20
+
21
+ def fire(name, *args)
22
+ Array.new(get_handler(name)).each do |fn|
23
+ fn.call(*args)
24
+ end
25
+
26
+ Array.new(get_handler(:*)).each do |fn|
27
+ fn.call(name, *args)
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def get_handler(name)
34
+ __handlers[name] ||= []
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,77 @@
1
+ require 'logger'
2
+
3
+ module Satori
4
+ module RTM
5
+ # Logger for Satori RTM SDK
6
+ module Logger
7
+ ENV_FLAG = 'DEBUG_SATORI_SDK'.freeze
8
+
9
+ class << self
10
+ def create_logger(level, output = $stderr)
11
+ logger = ::Logger.new(output)
12
+ logger.level = level
13
+ logger.progname = 'satori-rtm-sdk'
14
+ logger.formatter = lambda do |severity, datetime, progname, msg|
15
+ formatted_message = case msg
16
+ when String
17
+ msg
18
+ when Exception
19
+ format "%s (%s)\n%s",
20
+ msg.message, msg.class, (msg.backtrace || []).join("\n")
21
+ else
22
+ msg.inspect
23
+ end
24
+ format "%s [%5s] - %s: %s\n",
25
+ datetime.strftime('%H:%M:%S.%L'),
26
+ severity,
27
+ progname,
28
+ formatted_message
29
+ end
30
+ logger
31
+ end
32
+
33
+ # Sets a standard logger for all Satori RTM SDK classes.
34
+ #
35
+ # @param level [Keyword] logger log level
36
+ # @param output [IO] logger output
37
+ # @return [void]
38
+ def use_std_logger(level = default_level, output = $stderr)
39
+ use_logger create_logger(level, output)
40
+ end
41
+
42
+ # Sets a logger for all Satori RTM SDK classes.
43
+ #
44
+ # @param value [::Logger] logger
45
+ # @return [void]
46
+ def use_logger(value)
47
+ @global_logger = value
48
+ end
49
+
50
+ # Returns current logger
51
+ #
52
+ # @return [::Logger] logger
53
+ def logger
54
+ @global_logger ||= use_std_logger
55
+ end
56
+
57
+ # Returns the default logger level
58
+ #
59
+ # @return [Keyword] default logger level
60
+ def default_level
61
+ ENV.key?(ENV_FLAG) ? ::Logger::DEBUG : ::Logger::WARN
62
+ end
63
+
64
+ def included(klass)
65
+ klass.__send__ :include, InstanceMethods
66
+ end
67
+ end
68
+
69
+ # @!visibility private
70
+ module InstanceMethods
71
+ def logger
72
+ Satori::RTM::Logger.logger
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,180 @@
1
+ module Satori
2
+ module RTM
3
+ # Event about new subscription data or subscription status change.
4
+ #
5
+ # @!attribute [r] data
6
+ # Returns an event data. In most cases it represent +body+ field from incoming
7
+ # subscribe / subscription / unsubscribe PDUs. The type of PDU is accessible by
8
+ # +type+ attribute. Information about fields in +data+ could be found in RTM API
9
+ # specification.
10
+ # @return [Hash] event data
11
+ #
12
+ # @!attribute [r] type
13
+ # Returns a type of event.
14
+ # @return [:init] event before +rtm/subscribe+ request is sent
15
+ # @return [:subscribed] event when +rtm/subscribe/ok+ is received
16
+ # @return [:unsubscribed] event when +rtm/unsubscribe/ok+ is received
17
+ # @return [:error] event when +rtm/subscription/error+ / +rtm/subscribe/error+ are received
18
+ # @return [:info] event when +rtm/subscription/info+ is received
19
+ # @return [:disconnect] event when connection is lost
20
+ class SubscriptionEvent
21
+ attr_reader :data, :type
22
+
23
+ def initialize(type, data)
24
+ if type == :pdu
25
+ @type = resolve_type(data[:action])
26
+ @data = data[:body]
27
+ else
28
+ @type = type
29
+ @data = data
30
+ end
31
+ end
32
+
33
+ # Returns +true+ if event is an error PDU.
34
+ #
35
+ # @return [Boolean] +true+ if event is an error PDU, +false+ otherwise
36
+ def error?
37
+ type == :error
38
+ end
39
+
40
+ private
41
+
42
+ def resolve_type(action)
43
+ case action
44
+ when 'rtm/subscribe/ok'
45
+ :subscribed
46
+ when 'rtm/unsubscribe/ok'
47
+ :unsubscribed
48
+ when 'rtm/subscription/info'
49
+ :info
50
+ when 'rtm/subscription/data'
51
+ :data
52
+ when 'rtm/subscription/error', 'rtm/subscribe/error', 'rtm/unsubscribe/error'
53
+ :error
54
+ end
55
+ end
56
+ end
57
+
58
+ # Base RTM reply for a request.
59
+ #
60
+ # @!attribute [r] data
61
+ # Returns a reply data. It represent +body+ field from reply PDUs from RTM.
62
+ # Information about fields in data could be found in RTM API specification.
63
+ # @return [Hash] event data
64
+ #
65
+ # @!attribute [r] type
66
+ # Returns a type of reply.
67
+ # @return [:ok] when RTM positive reply is received
68
+ # @return [:error] when RTM error is received
69
+ # @return [:disconnect] when connection is lost
70
+ class BaseReply
71
+ attr_reader :data, :type
72
+
73
+ def initialize(type, data)
74
+ if type == :pdu
75
+ @type = data[:action].end_with?('/ok') ? :ok : :error
76
+ @data = data[:body]
77
+ else
78
+ @type = type
79
+ @data = data
80
+ end
81
+ end
82
+
83
+ # Returns +true+ if a reply is positive
84
+ #
85
+ # @return [Boolean] +true+ if a reply is positive, +false+ otherwise
86
+ def success?
87
+ type == :ok
88
+ end
89
+
90
+ # Returns +true+ if a reply is not positive
91
+ #
92
+ # @return [Boolean] +true+ if a reply is not positive, +false+ otherwise
93
+ def error?
94
+ !success?
95
+ end
96
+ end
97
+
98
+ # Close event with information why connection is closed or can't be established.
99
+ #
100
+ # @!attribute [r] code
101
+ # Returns a WebSocket close frame code
102
+ # @see https://tools.ietf.org/html/rfc6455 WebSocket RFC
103
+ # @return [Number] close code
104
+ #
105
+ # @!attribute [r] reason
106
+ # Returns a human-readable reason why connection is closed or can't be established.
107
+ # @return [String] close reason
108
+ class CloseEvent
109
+ attr_reader :code, :reason
110
+
111
+ def initialize(code, reason)
112
+ @code = code
113
+ @reason = reason
114
+ end
115
+
116
+ # Returns +true+ if connection was closed normally
117
+ # @return [Boolean] +true+ if connection was closed normally, +false+ otherwise
118
+ def normal?
119
+ @code == 1000
120
+ end
121
+ end
122
+
123
+ # Context with initial subscription settings and current subscription state.
124
+ #
125
+ # @!attribute [r] subscription_id
126
+ # @return [String] subscription identifier
127
+ #
128
+ # @!attribute [r] req_opts
129
+ # @return [Hash] additional options for +rtm/subscribe+ request used to create the subscription
130
+ #
131
+ # @!attribute [r] fn
132
+ # @return [Proc] RTM subscription yield block used to create the subscription
133
+ #
134
+ # @!attribute [r] position
135
+ # @return [String] current subscription position. Updated automatically after each RTM reply
136
+ #
137
+ # @!attribute [r] state
138
+ # Subscription state
139
+ # @return [:init] not established
140
+ # @return [:subscribed] subscribed
141
+ # @return [:unsubscribed] unsubscribed with +rtm/unsubscribe+ request
142
+ # @return [:disconnect] unsubscribed because connection is lost
143
+ # @return [:resubscribed] unsubscribed because new subscription replaces it with +force+ flag
144
+ class SubscriptionContext
145
+ attr_reader :subscription_id, :req_opts, :fn, :position, :state
146
+
147
+ def initialize(sid, opts, fn)
148
+ raise ArgumentError, 'subscription callback function should be specified' if fn.nil?
149
+
150
+ @subscription_id = sid
151
+ @fn = fn
152
+ @req_opts = opts
153
+ @position = nil
154
+ @state = :init
155
+ end
156
+
157
+ # @!visibility private
158
+ def handle_data(status, data)
159
+ data[:subscription_id] = @subscription_id if status == :disconnect
160
+ reply = SubscriptionEvent.new(status, data)
161
+ handle_reply(reply)
162
+ reply
163
+ end
164
+
165
+ # @!visibility private
166
+ def handle_reply(reply)
167
+ @position = reply.data[:position] if reply.data.key?(:position)
168
+ case reply.type
169
+ when :subscribed, :unsubscribed, :error, :disconnect
170
+ @state = reply.type
171
+ end
172
+ end
173
+
174
+ # @!visibility private
175
+ def mark_as_resubscribed
176
+ @state = :resubscribed
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,5 @@
1
+ module Satori
2
+ module RTM
3
+ VERSION = '0.0.1'.freeze
4
+ end
5
+ end
@@ -0,0 +1,189 @@
1
+ require 'socket'
2
+ require 'openssl'
3
+ require 'websocket'
4
+
5
+ module Satori
6
+ # Satori RTM classes.
7
+ module RTM
8
+ # Error class for any connection related error.
9
+ class ConnectionError < StandardError
10
+ end
11
+
12
+ # WebSocket implementation on top of standard TCP sockets.
13
+ class WebSocket
14
+ include EventEmitter
15
+ include Logger
16
+
17
+ attr_reader :socket
18
+
19
+ def initialize(opts = {})
20
+ @opts = opts
21
+ @state = :none
22
+ end
23
+
24
+ def connect(url)
25
+ uri = URI.parse(url)
26
+
27
+ @socket = create_socket(uri)
28
+ @socket = start_tls(@socket, uri.host) if uri.scheme == 'wss'
29
+
30
+ @hs = ::WebSocket::Handshake::Client.new(url: url)
31
+ @frame = incoming_frame.new(version: @hs.version)
32
+
33
+ @state = :open
34
+ do_ws_handshake
35
+ fire :open
36
+ rescue => ex
37
+ close 1006, ex.message
38
+ raise ConnectionError, ex.message, ex.backtrace
39
+ end
40
+
41
+ def read_nonblock
42
+ data = nil
43
+ begin
44
+ data = @socket.read_nonblock(1024)
45
+ rescue IO::WaitReadable, IO::WaitWritable
46
+ return :would_block
47
+ end
48
+
49
+ @frame << data
50
+ while (frame = @frame.next)
51
+ handle_incoming_frame(frame)
52
+ end
53
+ :ok
54
+ rescue => ex
55
+ close 1006, 'Socket is closed'
56
+ raise ConnectionError, ex.message, ex.backtrace
57
+ end
58
+
59
+ def read
60
+ while (rc = read_nonblock) == :would_block
61
+ IO.select([@socket])
62
+ end
63
+ rc
64
+ rescue => ex
65
+ close 1006, 'Socket is closed'
66
+ raise ConnectionError, ex.message, ex.backtrace
67
+ end
68
+
69
+ def read_with_timeout(timeout_in_secs)
70
+ now = Time.now
71
+ while (rc = read_nonblock) == :would_block
72
+ diff = Time.now - now
73
+ if timeout_in_secs <= diff
74
+ rc = :timeout
75
+ break
76
+ end
77
+ IO.select([@socket], nil, nil, timeout_in_secs - diff)
78
+ end
79
+ rc
80
+ rescue => ex
81
+ close 1006, 'Socket is closed'
82
+ raise ConnectionError, ex.message, ex.backtrace
83
+ end
84
+
85
+ def send(data, args)
86
+ type = args[:type] || :text
87
+ send_frame_unsafe(data, type, args[:code])
88
+ rescue => ex
89
+ close 1006, 'Socket is closed'
90
+ raise ConnectionError, ex.message, ex.backtrace
91
+ end
92
+
93
+ def close(code = 1000, reason = nil)
94
+ if @state == :open
95
+ send_frame_unsafe(reason, :close, code)
96
+ elsif @state == :server_close_frame_received
97
+ # server send close frame, replying back with default code
98
+ send_frame_unsafe(reason, :close)
99
+ end
100
+ rescue => ex
101
+ # ignore
102
+ logger.info("fail to close socket: #{ex.message}")
103
+ ensure
104
+ should_trigger_on_close = opened?
105
+ @state = :closed
106
+ @socket.close if @socket
107
+ fire :close, code, reason if should_trigger_on_close
108
+ end
109
+
110
+ private
111
+
112
+ def opened?
113
+ %i[open server_close_frame_received].include?(@state)
114
+ end
115
+
116
+ def incoming_frame
117
+ ::WebSocket::Frame::Incoming::Client
118
+ end
119
+
120
+ def outgoing_frame
121
+ ::WebSocket::Frame::Outgoing::Client
122
+ end
123
+
124
+ def send_frame_unsafe(data, type = :text, code = nil)
125
+ frame = create_out_frame(data, type, code)
126
+ return if frame.nil?
127
+ @socket.write frame
128
+ @socket.flush
129
+ end
130
+
131
+ def handle_incoming_frame(frame)
132
+ case frame.type
133
+ when :close
134
+ @state = :server_close_frame_received
135
+ close frame.code, frame.data
136
+ when :pong
137
+ fire :pong, frame.data
138
+ when :text
139
+ fire :message, frame.data, :text
140
+ end
141
+ end
142
+
143
+ def create_socket(uri)
144
+ host = uri.host
145
+ port = uri.port
146
+ port ||= uri.scheme == 'wss' ? 443 : 80
147
+ TCPSocket.new(host, port)
148
+ end
149
+
150
+ def start_tls(socket, hostname)
151
+ ctx = OpenSSL::SSL::SSLContext.new
152
+ ctx.verify_mode = OpenSSL::SSL::VERIFY_PEER | OpenSSL::SSL::VERIFY_FAIL_IF_NO_PEER_CERT
153
+
154
+ cert_store = OpenSSL::X509::Store.new
155
+ cert_store.set_default_paths
156
+ ctx.cert_store = cert_store
157
+
158
+ ssl_sock = OpenSSL::SSL::SSLSocket.new(socket, ctx)
159
+ # use undocumented method in SSLSocket to make it works with SNI
160
+ ssl_sock.hostname = hostname if ssl_sock.respond_to? :hostname=
161
+ ssl_sock.sync_close = true
162
+ ssl_sock.connect
163
+
164
+ ssl_sock
165
+ end
166
+
167
+ def do_ws_handshake
168
+ send(@hs.to_s, type: :plain)
169
+
170
+ while (line = @socket.gets)
171
+ @hs << line
172
+ break if @hs.finished?
173
+ end
174
+
175
+ unless @hs.valid?
176
+ raise ConnectionError, 'handshake error: ' + @hs.error.to_s
177
+ end
178
+ end
179
+
180
+ def create_out_frame(data, type, code)
181
+ if type == :plain
182
+ data
183
+ else
184
+ outgoing_frame.new(version: @hs.version, data: data, type: type, code: code).to_s
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end
metadata ADDED
@@ -0,0 +1,184 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: satori-rtm-sdk
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1.rc1
5
+ platform: ruby
6
+ authors:
7
+ - Andrey Vasenin
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2017-08-25 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: websocket
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: json
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.1'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.1'
41
+ - !ruby/object:Gem::Dependency
42
+ name: bundler
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: websocket-eventmachine-client
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '1.2'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '1.2'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rspec_junit_formatter
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: 0.3.0
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: 0.3.0
97
+ - !ruby/object:Gem::Dependency
98
+ name: rubocop
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: 0.49.1
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: 0.49.1
111
+ - !ruby/object:Gem::Dependency
112
+ name: simplecov
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: 0.15.0
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: 0.15.0
125
+ - !ruby/object:Gem::Dependency
126
+ name: yard
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: 0.9.0
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: 0.9.0
139
+ description:
140
+ email: sdk@satori.com
141
+ executables: []
142
+ extensions: []
143
+ extra_rdoc_files:
144
+ - README.md
145
+ files:
146
+ - CHANGELOG.md
147
+ - README.md
148
+ - lib/satori-rtm-sdk.rb
149
+ - lib/satori-rtm-sdk/client.rb
150
+ - lib/satori-rtm-sdk/codec.rb
151
+ - lib/satori-rtm-sdk/event_emitter.rb
152
+ - lib/satori-rtm-sdk/logger.rb
153
+ - lib/satori-rtm-sdk/model.rb
154
+ - lib/satori-rtm-sdk/version.rb
155
+ - lib/satori-rtm-sdk/websocket.rb
156
+ homepage: https://github.com/satori-com/satori-rtm-sdk-ruby
157
+ licenses:
158
+ - BSD-3-Clause
159
+ metadata: {}
160
+ post_install_message:
161
+ rdoc_options:
162
+ - "--main"
163
+ - README.md
164
+ - "--markup"
165
+ - markdown
166
+ require_paths:
167
+ - lib
168
+ required_ruby_version: !ruby/object:Gem::Requirement
169
+ requirements:
170
+ - - ">="
171
+ - !ruby/object:Gem::Version
172
+ version: '0'
173
+ required_rubygems_version: !ruby/object:Gem::Requirement
174
+ requirements:
175
+ - - ">"
176
+ - !ruby/object:Gem::Version
177
+ version: 1.3.1
178
+ requirements: []
179
+ rubyforge_project:
180
+ rubygems_version: 2.6.11
181
+ signing_key:
182
+ specification_version: 4
183
+ summary: Ruby SDK for Satori RTM
184
+ test_files: []