satori-rtm-sdk 0.0.1.rc1

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