ably 0.1.4 → 0.1.5

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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/ably.gemspec +1 -0
  3. data/lib/ably/auth.rb +9 -13
  4. data/lib/ably/models/idiomatic_ruby_wrapper.rb +27 -39
  5. data/lib/ably/modules/conversions.rb +31 -10
  6. data/lib/ably/modules/enum.rb +201 -0
  7. data/lib/ably/modules/event_emitter.rb +81 -0
  8. data/lib/ably/modules/event_machine_helpers.rb +21 -0
  9. data/lib/ably/modules/http_helpers.rb +13 -0
  10. data/lib/ably/modules/state.rb +67 -0
  11. data/lib/ably/realtime.rb +6 -1
  12. data/lib/ably/realtime/channel.rb +117 -56
  13. data/lib/ably/realtime/client.rb +7 -50
  14. data/lib/ably/realtime/client/incoming_message_dispatcher.rb +116 -0
  15. data/lib/ably/realtime/client/outgoing_message_dispatcher.rb +63 -0
  16. data/lib/ably/realtime/connection.rb +97 -14
  17. data/lib/ably/realtime/models/error_info.rb +3 -2
  18. data/lib/ably/realtime/models/message.rb +28 -3
  19. data/lib/ably/realtime/models/nil_channel.rb +21 -0
  20. data/lib/ably/realtime/models/protocol_message.rb +35 -27
  21. data/lib/ably/rest/client.rb +39 -23
  22. data/lib/ably/rest/middleware/external_exceptions.rb +1 -1
  23. data/lib/ably/rest/middleware/parse_json.rb +7 -2
  24. data/lib/ably/rest/middleware/parse_message_pack.rb +23 -0
  25. data/lib/ably/rest/models/paged_resource.rb +4 -4
  26. data/lib/ably/util/pub_sub.rb +32 -0
  27. data/lib/ably/version.rb +1 -1
  28. data/spec/acceptance/realtime/channel_spec.rb +1 -0
  29. data/spec/acceptance/realtime/message_spec.rb +136 -0
  30. data/spec/acceptance/rest/base_spec.rb +51 -1
  31. data/spec/acceptance/rest/presence_spec.rb +7 -2
  32. data/spec/integration/modules/state_spec.rb +66 -0
  33. data/spec/{unit → integration/rest}/auth.rb +0 -0
  34. data/spec/support/api_helper.rb +5 -2
  35. data/spec/support/protocol_msgbus_helper.rb +29 -0
  36. data/spec/support/test_app.rb +14 -3
  37. data/spec/unit/{conversions.rb → modules/conversions_spec.rb} +1 -1
  38. data/spec/unit/modules/enum_spec.rb +263 -0
  39. data/spec/unit/modules/event_emitter_spec.rb +81 -0
  40. data/spec/unit/modules/pub_sub_spec.rb +74 -0
  41. data/spec/unit/realtime/channel_spec.rb +27 -0
  42. data/spec/unit/realtime/client_spec.rb +8 -0
  43. data/spec/unit/realtime/connection_spec.rb +40 -0
  44. data/spec/unit/realtime/error_info_spec.rb +9 -1
  45. data/spec/unit/realtime/incoming_message_dispatcher_spec.rb +36 -0
  46. data/spec/unit/realtime/message_spec.rb +2 -2
  47. data/spec/unit/realtime/protocol_message_spec.rb +78 -9
  48. data/spec/unit/rest/{rest_spec.rb → client_spec.rb} +0 -0
  49. data/spec/unit/rest/message_spec.rb +1 -1
  50. metadata +51 -9
  51. data/lib/ably/realtime/callbacks.rb +0 -15
@@ -0,0 +1,21 @@
1
+ module Ably::Modules
2
+ module EventMachineHelpers
3
+ private
4
+
5
+ # This method allows looped blocks to be run at the next EventMachine tick
6
+ # @example
7
+ # x = 0
8
+ # less_than_3 = -> { x < 3 }
9
+ # non_blocking_loop_while(less_than_3) do
10
+ # x += 1
11
+ # end
12
+ def non_blocking_loop_while(lambda, &execution_block)
13
+ if lambda.call
14
+ yield
15
+ EventMachine.next_tick do
16
+ non_blocking_loop_while(lambda, &execution_block)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -1,5 +1,9 @@
1
1
  require 'base64'
2
2
 
3
+ require 'ably/rest/middleware/external_exceptions'
4
+ require 'ably/rest/middleware/parse_json'
5
+ require 'ably/rest/middleware/parse_message_pack'
6
+
3
7
  module Ably::Modules
4
8
  module HttpHelpers
5
9
  protected
@@ -10,5 +14,14 @@ module Ably::Modules
10
14
  def user_agent
11
15
  "Ably Ruby client #{Ably::VERSION} (https://ably.io)"
12
16
  end
17
+
18
+ def setup_middleware(builder)
19
+ # Convert request params to "www-form-urlencoded"
20
+ builder.use Faraday::Request::UrlEncoded
21
+
22
+ # Parse JSON / MsgPack response bodies. ParseJson must be first (default) parsing middleware
23
+ builder.use Ably::Rest::Middleware::ParseJson
24
+ builder.use Ably::Rest::Middleware::ParseMessagePack
25
+ end
13
26
  end
14
27
  end
@@ -0,0 +1,67 @@
1
+ module Ably::Modules
2
+ # State module adds a set of generic state related methods to a class on the assumption that
3
+ # the instance variable @state is used exclusively, the {Enum} STATE is defined prior to inclusion of this
4
+ # module, and the class is an {EventEmitter}
5
+ #
6
+ # @example
7
+ # class Connection
8
+ # include Ably::Modules::EventEmitter
9
+ # extend Ably::Modules::Enum
10
+ # STATE = ruby_enum('STATE',
11
+ # :initialized,
12
+ # :connecting,
13
+ # :connected
14
+ # )
15
+ # include Ably::Modules::State
16
+ # end
17
+ #
18
+ # connection = Connection.new
19
+ # connection.state = :connecting # emits :connecting event via EventEmitter, returns STATE.Connecting
20
+ # connection.state?(:connected) # => false
21
+ # connection.connecting? # => true
22
+ # connection.state # => STATE.Connecting
23
+ # connection.state = :invalid # raises an Exception as only a valid state can be defined
24
+ # connection.trigger :invalid # raises an Exception as only a valid state can be used for EventEmitter
25
+ # connection.change_state :connected # emits :connected event via EventEmitter, returns STATE.Connected
26
+ #
27
+ module State
28
+ # Current state {Ably::Modules::Enum}
29
+ #
30
+ # @return [Symbol] state
31
+ def state
32
+ STATE(@state)
33
+ end
34
+
35
+ # Evaluates if check_state matches current state
36
+ #
37
+ # @return [Boolean]
38
+ def state?(check_state)
39
+ state == check_state
40
+ end
41
+
42
+ # Set the current state {Ably::Modules::Enum}
43
+ #
44
+ # @return [Symbol] new state
45
+ def state=(new_state)
46
+ if state != new_state
47
+ logger.debug("#{self.class}: State changed from #{state} => #{new_state}") if respond_to?(:logger, true)
48
+ @state = STATE(new_state)
49
+ trigger @state
50
+ end
51
+ end
52
+ alias_method :change_state, :state=
53
+
54
+ private
55
+ def self.included(klass)
56
+ klass.configure_event_emitter coerce_into: Proc.new { |event| klass::STATE(event) }
57
+
58
+ klass::STATE.each do |state_predicate|
59
+ klass.instance_eval do
60
+ define_method("#{state_predicate.to_sym}?") do
61
+ state?(state_predicate)
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -1,7 +1,8 @@
1
1
  require "eventmachine"
2
2
  require "websocket/driver"
3
3
 
4
- require "ably/realtime/callbacks"
4
+ require "ably/modules/event_emitter"
5
+
5
6
  require "ably/realtime/channel"
6
7
  require "ably/realtime/client"
7
8
  require "ably/realtime/connection"
@@ -9,8 +10,12 @@ require "ably/realtime/connection"
9
10
  require "ably/realtime/models/shared"
10
11
  require "ably/realtime/models/error_info"
11
12
  require "ably/realtime/models/message"
13
+ require "ably/realtime/models/nil_channel"
12
14
  require "ably/realtime/models/protocol_message"
13
15
 
16
+ require "ably/realtime/client/incoming_message_dispatcher"
17
+ require "ably/realtime/client/outgoing_message_dispatcher"
18
+
14
19
  module Ably
15
20
  module Realtime
16
21
  def self.new(*args)
@@ -1,59 +1,118 @@
1
1
  module Ably
2
2
  module Realtime
3
+ # The Channel class represents a Channel belonging to this application.
4
+ # The Channel instance allows messages to be published and
5
+ # received, and controls the lifecycle of this instance's
6
+ # attachment to the channel.
7
+ #
8
+ # Channels will always be in one of the following states:
9
+ #
10
+ # initialized: 0
11
+ # attaching: 1
12
+ # attached: 2
13
+ # detaching: 3
14
+ # detached: 4
15
+ # failed: 5
16
+ #
17
+ # Note that the states are available as Enum-like constants:
18
+ #
19
+ # Channel::STATE.Initialized
20
+ # Channel::STATE.Attaching
21
+ # Channel::STATE.Attached
22
+ # Channel::STATE.Detaching
23
+ # Channel::STATE.Detached
24
+ # Channel::STATE.Failed
25
+ #
26
+ # @!attribute [r] state
27
+ # @return {Ably::Realtime::Connection::STATE} channel state
28
+ #
3
29
  class Channel
4
30
  include Ably::Modules::Conversions
5
- include Callbacks
6
-
7
- STATES = {
8
- initialised: 1,
9
- attaching: 2,
10
- attached: 3,
11
- detaching: 4,
12
- detached: 5,
13
- failed: 6
14
- }.freeze
31
+ include Ably::Modules::EventEmitter
32
+ include Ably::Modules::EventMachineHelpers
33
+ extend Ably::Modules::Enum
34
+
35
+ STATE = ruby_enum('STATE',
36
+ :initialized,
37
+ :attaching,
38
+ :attached,
39
+ :detaching,
40
+ :detached,
41
+ :failed
42
+ )
43
+ include Ably::Modules::State
44
+
45
+ # Max number of messages to bundle in a single ProtocolMessage
46
+ MAX_PROTOCOL_MESSAGE_BATCH_SIZE = 50
15
47
 
16
48
  attr_reader :client, :name
17
49
 
18
- # Retrieve a state symbol by the integer value
19
- def self.state_sym_for(state_int)
20
- @states_index_by_int ||= STATES.invert.freeze
21
- @states_index_by_int[state_int]
22
- end
23
-
24
50
  def initialize(client, name)
25
- @client = client
26
- @name = name
27
- @subscriptions = Hash.new { |hash, key| hash[key] = [] }
28
- @queue = []
51
+ @client = client
52
+ @name = name
53
+ @subscriptions = Hash.new { |hash, key| hash[key] = [] }
54
+ @queue = []
55
+ @state = STATE.Initialized
29
56
 
30
- set_state :initialised
57
+ setup_event_handlers
58
+ end
31
59
 
32
- on(:message) do |message|
33
- @subscriptions[:all].each { |cb| cb.call(message) }
34
- @subscriptions[message.name].each { |cb| cb.call(message) }
60
+ # Publish a message on the channel
61
+ #
62
+ # @param event [String] The event name of the message
63
+ # @param data [String,ByteArray] payload for the message
64
+ # @yield [Ably::Realtime::Models::Message] On success, will call the block with the {Ably::Realtime::Models::Message}
65
+ # @return [Ably::Realtime::Models::Message]
66
+ #
67
+ def publish(event, data, &callback)
68
+ Models::Message.new({
69
+ name: event,
70
+ data: data,
71
+ timestamp: as_since_epoch(Time.now),
72
+ client_id: client.client_id
73
+ }, nil).tap do |message|
74
+ message.callback(&callback) if block_given?
75
+ queue_message message
35
76
  end
77
+ end
36
78
 
37
- on(:attached) do
38
- set_state :attached
39
- process_queue
79
+ def subscribe(event = :all, &blk)
80
+ event = event.to_s unless event == :all
81
+ attach unless attached? || attaching?
82
+ @subscriptions[event] << blk
83
+ end
84
+
85
+ def attach
86
+ unless attached? || attaching?
87
+ change_state STATE.Attaching
88
+ send_attach_protocol_message
40
89
  end
41
90
  end
42
91
 
43
- # Current Channel state, will always be one of {STATES}
44
- #
45
- # @return [Symbol] state
46
- def state
47
- self.class.state_sym_for(@state)
92
+ def __incoming_protocol_msgbus__
93
+ @__incoming_protocol_msgbus__ ||= Ably::Util::PubSub.new(
94
+ coerce_into: Proc.new { |event| Models::ProtocolMessage::ACTION(event) }
95
+ )
48
96
  end
49
97
 
50
- def state?(check_state)
51
- check_state = STATES.fetch(check_state) if check_state.kind_of?(Symbol)
52
- @state == check_state
98
+ private
99
+ attr_reader :queue
100
+
101
+ def setup_event_handlers
102
+ __incoming_protocol_msgbus__.subscribe(:message) do |message|
103
+ @subscriptions[:all].each { |cb| cb.call(message) }
104
+ @subscriptions[message.name].each { |cb| cb.call(message) }
105
+ end
106
+
107
+ on(:attached) do
108
+ process_queue
109
+ end
53
110
  end
54
111
 
55
- def publish(event, data)
56
- queue << { name: event, data: data, timestamp: as_since_epoch(Time.now) }
112
+ # Queue message and process queue if channel is attached.
113
+ # If channel is not yet attached, attempt to attach it before the message queue is processed.
114
+ def queue_message(message)
115
+ queue << message
57
116
 
58
117
  if attached?
59
118
  process_queue
@@ -62,34 +121,36 @@ module Ably
62
121
  end
63
122
  end
64
123
 
65
- def subscribe(event = :all, &blk)
66
- event = event.to_s unless event == :all
67
- attach unless attached?
68
- @subscriptions[event] << blk
124
+ def messages_in_queue?
125
+ !queue.empty?
69
126
  end
70
127
 
71
- def attach
72
- unless state?(:attaching)
73
- set_state :attaching
74
- client.attach_to_channel(name)
128
+ # Move messages from Channel Queue into Outgoing Connection Queue
129
+ def process_queue
130
+ condition = -> { attached? && messages_in_queue? }
131
+ non_blocking_loop_while(condition) do
132
+ send_messages_within_protocol_message(queue.shift(MAX_PROTOCOL_MESSAGE_BATCH_SIZE))
75
133
  end
76
134
  end
77
135
 
78
- def attached?
79
- state?(:attached)
136
+ def send_messages_within_protocol_message(messages)
137
+ client.connection.send_protocol_message(
138
+ action: Models::ProtocolMessage::ACTION.Message.to_i,
139
+ channel: name,
140
+ messages: messages
141
+ )
80
142
  end
81
143
 
82
- private
83
- attr_reader :queue
84
-
85
- def set_state(new_state)
86
- new_state = STATES.fetch(new_state) if new_state.kind_of?(Symbol)
87
- raise ArgumentError, "#{new_state} is not a valid state" unless STATES.values.include?(new_state)
88
- @state = new_state
144
+ def send_attach_protocol_message
145
+ client.connection.send_protocol_message(
146
+ action: Models::ProtocolMessage::ACTION.Attach.to_i,
147
+ channel: name
148
+ )
89
149
  end
90
150
 
91
- def process_queue
92
- client.send_messages(name, queue.shift(100)) until queue.empty?
151
+ # Used by {Ably::Modules::State} to debug state changes
152
+ def logger
153
+ client.logger
93
154
  end
94
155
  end
95
156
  end
@@ -13,14 +13,13 @@ module Ably
13
13
  # @!attribute [r] environment
14
14
  # (see Ably::Rest::Client#environment)
15
15
  class Client
16
- include Callbacks
17
16
  extend Forwardable
18
17
 
19
18
  DOMAIN = 'realtime.ably.io'
20
19
 
21
20
  attr_reader :channels, :auth
22
21
  def_delegators :auth, :client_id, :auth_options
23
- def_delegators :@rest_client, :tls, :environment, :use_tls?
22
+ def_delegators :@rest_client, :tls, :environment, :use_tls?, :logger, :log_level
24
23
 
25
24
  # Creates a {Ably::Realtime::Client Realtime Client} and configures the {Ably::Auth} object for the connection.
26
25
  #
@@ -29,7 +28,6 @@ module Ably
29
28
  # @option options [Boolean] :queue_messages If false, this disables the default behaviour whereby the library queues messages on a connection in the disconnected or connecting states
30
29
  # @option options [Boolean] :echo_messages If false, prevents messages originating from this connection being echoed back on the same connection
31
30
  # @option options [String] :recover This option allows a connection to inherit the state of a previous connection that may have existed under an different instance of the Realtime library.
32
- # @option options [Boolean] :debug_http Send HTTP & websocket debugging information for all messages/requests sent and received to STDOUT
33
31
  #
34
32
  # @yield (see Ably::Rest::Client#initialize)
35
33
  # @yieldparam (see Ably::Rest::Client#initialize)
@@ -48,20 +46,6 @@ module Ably
48
46
  @rest_client = Ably::Rest::Client.new(options)
49
47
  @auth = @rest_client.auth
50
48
  @message_serial = 0
51
-
52
- on(:attached) do |protocol_message|
53
- channel = channel(protocol_message.channel)
54
-
55
- channel.trigger(:attached)
56
- end
57
-
58
- on(:message) do |protocol_message|
59
- channel = channel(protocol_message.channel)
60
-
61
- protocol_message.messages.each do |message|
62
- channel.trigger(:message, message)
63
- end
64
- end
65
49
  end
66
50
 
67
51
  def token
@@ -77,27 +61,6 @@ module Ably
77
61
  @channels[name] ||= Ably::Realtime::Channel.new(self, name)
78
62
  end
79
63
 
80
- def send_messages(channel_name, messages)
81
- payload = {
82
- action: Models::ProtocolMessage.action!(:message),
83
- channel: channel_name,
84
- messages: messages
85
- }
86
-
87
- payload.merge!(clientId: client_id) unless client_id.nil?
88
-
89
- connection.send(payload)
90
- end
91
-
92
- def attach_to_channel(channel_name)
93
- payload = {
94
- action: Models::ProtocolMessage.action!(:attach),
95
- channel: channel_name
96
- }
97
-
98
- connection.send(payload)
99
- end
100
-
101
64
  # Default Ably Realtime endpoint used for all requests
102
65
  #
103
66
  # @return [URI::Generic]
@@ -113,21 +76,15 @@ module Ably
113
76
  host = endpoint.host
114
77
  port = use_tls? ? 443 : 80
115
78
 
116
- EventMachine.connect(host, port, Connection, self)
79
+ EventMachine.connect(host, port, Connection, self).tap do |connection|
80
+ connection.on(:connected) do
81
+ IncomingMessageDispatcher.new(self)
82
+ OutgoingMessageDispatcher.new(self)
83
+ end
84
+ end
117
85
  end
118
86
  end
119
87
 
120
- # When true, will send HTTP & websocket debugging information for all messages/requests sent and received to STDOUT
121
- #
122
- # @return [Boolean]
123
- def debug_http?
124
- rest_client.debug_http?
125
- end
126
-
127
- def log_http(message)
128
- $stdout.puts "#{Time.now.strftime('%H:%M:%S')} #{message}" if debug_http?
129
- end
130
-
131
88
  private
132
89
  attr_reader :rest_client
133
90
  end
@@ -0,0 +1,116 @@
1
+ module Ably::Realtime
2
+ class Client
3
+ # IncomingMessageDispatcher is a (private) class that is used to dispatch {Ably::Realtime::Models::ProtocolMessage} that are
4
+ # received from Ably via the {Ably::Realtime::Connection}
5
+ class IncomingMessageDispatcher
6
+ ACTION = Models::ProtocolMessage::ACTION
7
+
8
+ def initialize(client)
9
+ @client = client
10
+ subscribe_to_incoming_protocol_messages
11
+ end
12
+
13
+ private
14
+ attr_reader :client
15
+
16
+ def connection
17
+ client.connection
18
+ end
19
+
20
+ def channels
21
+ client.channels
22
+ end
23
+
24
+ def get_channel(channel_name)
25
+ channels.fetch(channel_name) do
26
+ logger.warn "Received channel message for non-existent channel"
27
+ Models::NilChannel.new
28
+ end
29
+ end
30
+
31
+ def logger
32
+ client.logger
33
+ end
34
+
35
+ def dispatch_protocol_message(*args)
36
+ protocol_message = args.first
37
+
38
+ unless protocol_message.kind_of?(Models::ProtocolMessage)
39
+ raise ArgumentError, "Expected a ProtocolMessage. Received #{protocol_message}"
40
+ end
41
+
42
+ unless [:nack, :error].include?(protocol_message.action)
43
+ logger.debug "#{protocol_message.action} received: #{protocol_message}"
44
+ end
45
+
46
+ case protocol_message.action
47
+ when ACTION.Heartbeat
48
+ when ACTION.Ack
49
+ ack_pending_queue_for_message_serial(protocol_message) if protocol_message.has_message_serial?
50
+
51
+ when ACTION.Nack
52
+ logger.warn "NACK received: #{protocol_message}"
53
+ nack_pending_queue_for_message_serial(protocol_message) if protocol_message.has_message_serial?
54
+
55
+ when ACTION.Connect, ACTION.Connected
56
+ when ACTION.Disconnect, ACTION.Disconnected
57
+ when ACTION.Close
58
+ when ACTION.Closed
59
+ when ACTION.Error
60
+ logger.error "Error received: #{protocol_message.error}"
61
+
62
+ when ACTION.Attach
63
+ when ACTION.Attached
64
+ get_channel(protocol_message.channel).change_state Ably::Realtime::Channel::STATE.Attached
65
+
66
+ when ACTION.Detach
67
+ when ACTION.Detached
68
+ get_channel(protocol_message.channel).change_state Ably::Realtime::Channel::STATE.Detached
69
+
70
+ when ACTION.Presence
71
+ when ACTION.Message
72
+ protocol_message.messages.each do |message|
73
+ get_channel(protocol_message.channel).__incoming_protocol_msgbus__.publish :message, message
74
+ end
75
+
76
+ else
77
+ raise ArgumentError, "Protocol Message Action #{protocol_message.action} is unsupported by this MessageDispatcher"
78
+ end
79
+ end
80
+
81
+ def ack_pending_queue_for_message_serial(ack_protocol_message)
82
+ drop_pending_queue_from_ack(ack_protocol_message) do |protocol_message|
83
+ protocol_message.messages.each do |message|
84
+ logger.debug "Calling ACK success callbacks for #{message.to_json}"
85
+ message.succeed message
86
+ end
87
+ end
88
+ end
89
+
90
+ def nack_pending_queue_for_message_serial(nack_protocol_message)
91
+ drop_pending_queue_from_ack(nack_protocol_message) do |protocol_message|
92
+ protocol_message.messages.each do |message|
93
+ logger.debug "Calling NACK failure callbacks for #{message.to_json}"
94
+ message.fail message, nack_protocol_message.error
95
+ end
96
+ end
97
+ end
98
+
99
+ def drop_pending_queue_from_ack(ack_protocol_message)
100
+ message_serial_up_to = ack_protocol_message.message_serial + ack_protocol_message.count - 1
101
+ connection.__pending_message_queue__.drop_while do |protocol_message|
102
+ if protocol_message.message_serial <= message_serial_up_to
103
+ yield protocol_message
104
+ true
105
+ end
106
+ end
107
+ end
108
+
109
+ def subscribe_to_incoming_protocol_messages
110
+ connection.__incoming_protocol_msgbus__.subscribe(:message) do |*args|
111
+ dispatch_protocol_message *args
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end