ably 0.1.0

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 (41) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +4 -0
  3. data/Gemfile +4 -0
  4. data/LICENSE.txt +22 -0
  5. data/README.md +103 -0
  6. data/Rakefile +4 -0
  7. data/ably.gemspec +32 -0
  8. data/lib/ably.rb +11 -0
  9. data/lib/ably/auth.rb +381 -0
  10. data/lib/ably/exceptions.rb +16 -0
  11. data/lib/ably/realtime.rb +38 -0
  12. data/lib/ably/realtime/callbacks.rb +15 -0
  13. data/lib/ably/realtime/channel.rb +51 -0
  14. data/lib/ably/realtime/client.rb +82 -0
  15. data/lib/ably/realtime/connection.rb +61 -0
  16. data/lib/ably/rest.rb +15 -0
  17. data/lib/ably/rest/channel.rb +58 -0
  18. data/lib/ably/rest/client.rb +194 -0
  19. data/lib/ably/rest/middleware/exceptions.rb +42 -0
  20. data/lib/ably/rest/middleware/external_exceptions.rb +26 -0
  21. data/lib/ably/rest/middleware/parse_json.rb +15 -0
  22. data/lib/ably/rest/paged_resource.rb +107 -0
  23. data/lib/ably/rest/presence.rb +44 -0
  24. data/lib/ably/support.rb +14 -0
  25. data/lib/ably/token.rb +55 -0
  26. data/lib/ably/version.rb +3 -0
  27. data/spec/acceptance/realtime_client_spec.rb +12 -0
  28. data/spec/acceptance/rest/auth_spec.rb +441 -0
  29. data/spec/acceptance/rest/base_spec.rb +113 -0
  30. data/spec/acceptance/rest/channel_spec.rb +68 -0
  31. data/spec/acceptance/rest/presence_spec.rb +22 -0
  32. data/spec/acceptance/rest/stats_spec.rb +57 -0
  33. data/spec/acceptance/rest/time_spec.rb +14 -0
  34. data/spec/spec_helper.rb +31 -0
  35. data/spec/support/api_helper.rb +41 -0
  36. data/spec/support/test_app.rb +77 -0
  37. data/spec/unit/auth.rb +9 -0
  38. data/spec/unit/realtime_spec.rb +9 -0
  39. data/spec/unit/rest_spec.rb +99 -0
  40. data/spec/unit/token_spec.rb +90 -0
  41. metadata +240 -0
@@ -0,0 +1,16 @@
1
+ module Ably
2
+ class InvalidRequest < StandardError
3
+ attr_reader :status, :code
4
+ def initialize(message, status: nil, code: nil)
5
+ super message
6
+ @status = status
7
+ @code = code
8
+ end
9
+ end
10
+
11
+ class ServerError < StandardError; end
12
+ class InvalidPageError < StandardError; end
13
+ class InvalidResponseBody < StandardError; end
14
+ class InsecureRequestError < StandardError; end
15
+ class TokenRequestError < StandardError; end
16
+ end
@@ -0,0 +1,38 @@
1
+ require "eventmachine"
2
+ require "websocket/driver"
3
+
4
+ require "ably/realtime/callbacks"
5
+ require "ably/realtime/channel"
6
+ require "ably/realtime/client"
7
+ require "ably/realtime/connection"
8
+
9
+ module Ably
10
+ module Realtime
11
+ # Actions which are sent by the Ably Realtime API
12
+ #
13
+ # The values correspond to the ints which the API
14
+ # understands.
15
+ ACTIONS = {
16
+ heartbeat: 0,
17
+ ack: 1,
18
+ nack: 2,
19
+ connect: 3,
20
+ connected: 4,
21
+ disconnect: 5,
22
+ disconnected: 6,
23
+ close: 7,
24
+ closed: 8,
25
+ error: 9,
26
+ attach: 10,
27
+ attached: 11,
28
+ detach: 12,
29
+ detached: 13,
30
+ presence: 14,
31
+ message: 15
32
+ }
33
+
34
+ def self.new(*args)
35
+ Ably::Realtime::Client.new(*args)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,15 @@
1
+ module Ably
2
+ module Realtime
3
+ module Callbacks
4
+ def on(event, &block)
5
+ @callbacks ||= Hash.new { |hash, key| hash[key] = [] }
6
+ @callbacks[event] << block
7
+ end
8
+
9
+ def trigger(event, *args)
10
+ @callbacks ||= Hash.new { |hash, key| hash[key] = [] }
11
+ @callbacks[event].each { |cb| cb.call(*args) }
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,51 @@
1
+ module Ably
2
+ module Realtime
3
+ class Channel
4
+ include Callbacks
5
+
6
+ attr_reader :client, :name
7
+
8
+ def initialize(client, name)
9
+ @state = :initialised
10
+ @client = client
11
+ @name = name
12
+ @subscriptions = Hash.new { |hash, key| hash[key] = [] }
13
+
14
+ on(:message) do |message|
15
+ event = message[:name]
16
+
17
+ @subscriptions[:all].each { |cb| cb.call(message) }
18
+ @subscriptions[event].each { |cb| cb.call(message) }
19
+ end
20
+ end
21
+
22
+ def publish(event, data)
23
+ message = { name: event, data: data }
24
+
25
+ if attached?
26
+ client.send_message(name, message)
27
+ else
28
+ on(:attached) { client.send_message(name, message) }
29
+ attach
30
+ end
31
+ end
32
+
33
+ def subscribe(event = :all, &blk)
34
+ @subscriptions[event] << blk
35
+ end
36
+
37
+ private
38
+ def attached?
39
+ @state == :attached
40
+ end
41
+
42
+ def attach
43
+ unless @state == :attaching
44
+ @state = :attaching
45
+ client.attach_to_channel(name)
46
+ on(:attached) { @state = :attached }
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,82 @@
1
+ module Ably
2
+ module Realtime
3
+ # A client for the Ably Realtime API
4
+ class Client
5
+ include Callbacks
6
+
7
+ DOMAIN = "staging-realtime.ably.io"
8
+
9
+ def initialize(options)
10
+ @rest_client = Ably::Rest::Client.new(options)
11
+
12
+ on(:attached) do |data|
13
+ channel = channel(data[:channel])
14
+
15
+ channel.trigger(:attached)
16
+ end
17
+
18
+ on(:message) do |data|
19
+ channel = channel(data[:channel])
20
+
21
+ data[:messages].each do |message|
22
+ channel.trigger(:message, message)
23
+ end
24
+ end
25
+ end
26
+
27
+ def token
28
+ @token ||= @rest_client.request_token
29
+ end
30
+
31
+ # Return a Realtime Channel for the given name
32
+ #
33
+ # @param name [String] The name of the channel
34
+ # @return [Ably::Realtime::Channel]
35
+ def channel(name)
36
+ @channels ||= {}
37
+ @channels[name] ||= Ably::Realtime::Channel.new(self, name)
38
+ end
39
+
40
+ def send_message(channel_name, message)
41
+ payload = {
42
+ action: ACTIONS[:message],
43
+ channel: channel_name,
44
+ messages: [message]
45
+ }.to_json
46
+
47
+ connection.send(payload)
48
+ end
49
+
50
+ def attach_to_channel(channel_name)
51
+ payload = {
52
+ action: ACTIONS[:attach],
53
+ channel: channel_name
54
+ }.to_json
55
+
56
+ connection.send(payload)
57
+ end
58
+
59
+ def use_tls?
60
+ @rest_client.use_tls?
61
+ end
62
+
63
+ def endpoint
64
+ @endpoint ||= URI::Generic.build(
65
+ scheme: use_tls? ? "wss" : "ws",
66
+ host: DOMAIN,
67
+ query: "access_token=#{token.id}&binary=false&timestamp=#{Time.now.to_i}"
68
+ )
69
+ end
70
+
71
+ def connection
72
+ @connection ||= begin
73
+ host = endpoint.host
74
+ port = use_tls? ? 443 : 80
75
+
76
+ EventMachine.connect(host, port, Connection, self)
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+
@@ -0,0 +1,61 @@
1
+ module Ably
2
+ module Realtime
3
+ class Connection < EventMachine::Connection
4
+ include Callbacks
5
+
6
+ def initialize(client)
7
+ @client = client
8
+ end
9
+
10
+ # Ably::Realtime interface
11
+ def send(data)
12
+ @driver.text(data)
13
+ end
14
+
15
+ # EventMachine::Connection interface
16
+ def post_init
17
+ trigger :initalised
18
+
19
+ setup_driver
20
+ end
21
+
22
+ def connection_completed
23
+ trigger :connecting
24
+
25
+ start_tls if @client.use_tls?
26
+ @driver.start
27
+ end
28
+
29
+ def receive_data(data)
30
+ @driver.parse(data)
31
+ end
32
+
33
+ def unbind
34
+ trigger :disconnected
35
+ end
36
+
37
+ # WebSocket::Driver interface
38
+ def url
39
+ @client.endpoint.to_s
40
+ end
41
+
42
+ def write(data)
43
+ send_data(data)
44
+ end
45
+
46
+ private
47
+ def setup_driver
48
+ @driver = WebSocket::Driver.client(self)
49
+
50
+ @driver.on("open") { trigger :connected }
51
+
52
+ @driver.on("message") do |event|
53
+ message = JSON.parse(event.data, symbolize_names: true)
54
+ action = ACTIONS.detect { |k,v| v == message[:action] }.first
55
+
56
+ @client.trigger action, message
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,15 @@
1
+ require "ably/rest/channel"
2
+ require "ably/rest/client"
3
+ require "ably/rest/paged_resource"
4
+ require "ably/rest/presence"
5
+
6
+ module Ably
7
+ module Rest
8
+ # Convenience method providing an alias to {Ably::Rest::Client} constructor.
9
+ #
10
+ # @return [Ably::Rest::Client]
11
+ def self.new(*args)
12
+ Ably::Rest::Client.new(*args)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,58 @@
1
+ module Ably
2
+ module Rest
3
+ class Channel
4
+ attr_reader :client, :name
5
+
6
+ # Initialize a new Channel object
7
+ #
8
+ # @param client [Ably::Rest::Client]
9
+ # @param name [String] The name of the channel
10
+ def initialize(client, name)
11
+ @client = client
12
+ @name = name
13
+ end
14
+
15
+ # Publish a message to the channel
16
+ #
17
+ # @param message [Hash] The message to publish (must contain :name and :data keys)
18
+ # @return [Boolean] true if the message was published, otherwise false
19
+ def publish(event, message)
20
+ payload = {
21
+ name: event,
22
+ data: message
23
+ }
24
+
25
+ response = client.post("#{base_path}/publish", payload)
26
+
27
+ response.status == 201
28
+ end
29
+
30
+ # Return the message history of the channel
31
+ #
32
+ # Options:
33
+ # - start: Time or millisecond since epoch
34
+ # - end: Time or millisecond since epoch
35
+ # - direction: :forwards or :backwards
36
+ # - limit: Maximum number of messages to retrieve up to 10,000
37
+ # - by: :message, :bundle or :hour. Defaults to :message
38
+ #
39
+ # @return [PagedResource] An Array of hashes representing the message history that supports paging (next, first)
40
+ def history(options = {})
41
+ url = "#{base_path}/messages"
42
+ # TODO: Remove live param as all history should be live
43
+ response = client.get(url, options.merge(live: true))
44
+
45
+ PagedResource.new(response, url, client)
46
+ end
47
+
48
+ def presence
49
+ @presence ||= Presence.new(client, self)
50
+ end
51
+
52
+ private
53
+ def base_path
54
+ "/channels/#{CGI.escape(name)}"
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,194 @@
1
+ require "json"
2
+ require "faraday"
3
+
4
+ require "ably/rest/middleware/exceptions"
5
+ require "ably/rest/middleware/parse_json"
6
+
7
+ module Ably
8
+ module Rest
9
+ # Wrapper for the Ably REST API
10
+ #
11
+ # @!attribute [r] auth
12
+ # @return {Ably::Auth} authentication object configured for this connection
13
+ # @!attribute [r] client_id
14
+ # @return [String] A client ID, used for identifying this client for presence purposes
15
+ # @!attribute [r] auth_options
16
+ # @return [Hash] {Ably::Auth} options configured for this client
17
+ # @!attribute [r] tls
18
+ # @return [Boolean] True if client is configured to use TLS for all Ably communication
19
+ # @!attribute [r] environment
20
+ # @return [String] May contain 'sandbox' when testing the client library against an alternate Ably environment
21
+ class Client
22
+ include Ably::Support
23
+ extend Forwardable
24
+
25
+ DOMAIN = "rest.ably.io"
26
+
27
+ attr_reader :tls, :environment, :auth
28
+ def_delegator :auth, :client_id, :auth_options
29
+
30
+ # Creates a {Ably::Rest::Client Rest Client} and configures the {Ably::Auth} object for the connection.
31
+ #
32
+ # @param [Hash,String] options an options Hash used to configure the client and the authentication, or String with an API key
33
+ # @option options [Boolean] :tls TLS is used by default, providing a value of false disbles TLS. Please note Basic Auth is disallowed without TLS as secrets cannot be transmitted over unsecured connections.
34
+ # @option options [String] :api_key API key comprising the key ID and key secret in a single string
35
+ # @option options [String] :key_id key ID for the designated application (defaults to client key_id)
36
+ # @option options [String] :key_secret key secret for the designated application used to sign token requests (defaults to client key_secret)
37
+ # @option options [String] :client_id client ID identifying this connection to other clients (defaults to client client_id if configured)
38
+ # @option options [String] :auth_url a URL to be used to GET or POST a set of token request params, to obtain a signed token request.
39
+ # @option options [Hash] :auth_headers a set of application-specific headers to be added to any request made to the authUrl
40
+ # @option options [Hash] :auth_params a set of application-specific query params to be added to any request made to the authUrl
41
+ # @option options [Symbol] :auth_method HTTP method to use with auth_url, must be either `:get` or `:post` (defaults to :get)
42
+ # @option options [Integer] :ttl validity time in seconds for the requested {Ably::Token}. Limits may apply, see {http://docs.ably.io/other/authentication/}
43
+ # @option options [Hash] :capability canonicalised representation of the resource paths and associated operations
44
+ # @option options [Boolean] :query_time when true will query the {https://ably.io Ably} system for the current time instead of using the local time
45
+ # @option options [Integer] :timestamp the time of the of the request in seconds since the epoch
46
+ # @option options [String] :nonce an unquoted, unescaped random string of at least 16 characters
47
+ # @option options [String] :environment Specify 'sandbox' when testing the client library against an alternate Ably environment
48
+ # @option options [Boolean] :debug_http Send HTTP debugging information from Faraday for all HTTP requests to STDOUT
49
+ #
50
+ # @yield [options] (optional) if an auth block is passed to this method, then this block will be called to create a new token request object
51
+ # @yieldparam [Hash] options options passed to request_token will be in turn sent to the block in this argument
52
+ # @yieldreturn [Hash] valid token request object, see {#create_token_request}
53
+ #
54
+ # @return [Ably::Rest::Client]
55
+ #
56
+ # @example
57
+ # # create a new client authenticating with basic auth
58
+ # client = Ably::Rest::Client.new('key.id:secret')
59
+ #
60
+ # # create a new client and configure a client ID used for presence
61
+ # client = Ably::Rest::Client.new(api_key: 'key.id:secret', client_id: 'john')
62
+ #
63
+ def initialize(options, &auth_block)
64
+ if options.kind_of?(String)
65
+ options = { api_key: options }
66
+ end
67
+
68
+ @tls = options.delete(:tls) == false ? false : true
69
+ @environment = options.delete(:environment) # nil is production
70
+ @debug_http = options.delete(:debug_http)
71
+
72
+ @auth = Auth.new(self, options, &auth_block)
73
+ end
74
+
75
+ # Return a REST {Ably::Rest::Channel} for the given name
76
+ #
77
+ # @param name [String] The name of the channel
78
+ # @return [Ably::Rest::Channel]
79
+ def channel(name)
80
+ @channels ||= {}
81
+ @channels[name] ||= Ably::Rest::Channel.new(self, name)
82
+ end
83
+
84
+ # Return the stats for the application
85
+ #
86
+ # @return [Array] An Array of hashes representing the stats
87
+ def stats(params = {})
88
+ default_params = {
89
+ :direction => :forwards,
90
+ :by => :minute
91
+ }
92
+
93
+ response = get("/stats", default_params.merge(params))
94
+
95
+ response.body
96
+ end
97
+
98
+ # Return the Ably service time
99
+ #
100
+ # @return [Time] The time as reported by the Ably service
101
+ def time
102
+ response = get('/time', {}, send_auth_header: false)
103
+
104
+ Time.at(response.body.first / 1000.0)
105
+ end
106
+
107
+ # True if client is configured to use TLS for all Ably communication
108
+ #
109
+ # @return [Boolean]
110
+ def use_tls?
111
+ @tls == true
112
+ end
113
+
114
+ # Perform an HTTP GET request to the API using configured authentication
115
+ #
116
+ # @return [Faraday::Response]
117
+ def get(path, params = {}, options = {})
118
+ request(:get, path, params, options)
119
+ end
120
+
121
+ # Perform an HTTP POST request to the API using configured authentication
122
+ #
123
+ # @return [Faraday::Response]
124
+ def post(path, params, options = {})
125
+ request(:post, path, params, options)
126
+ end
127
+
128
+ # Default Ably REST endpoint used for all requests
129
+ #
130
+ # @return [URI::Generic]
131
+ def endpoint
132
+ URI::Generic.build(
133
+ scheme: use_tls? ? "https" : "http",
134
+ host: [@environment, DOMAIN].compact.join('-')
135
+ )
136
+ end
137
+
138
+ private
139
+ def request(method, path, params = {}, options = {})
140
+ connection.send(method, path, params) do |request|
141
+ unless options[:send_auth_header] == false
142
+ request.headers[:authorization] = auth.auth_header
143
+ end
144
+ end
145
+ end
146
+
147
+ # Return a Faraday::Connection to use to make HTTP requests
148
+ #
149
+ # @return [Faraday::Connection]
150
+ def connection
151
+ @connection ||= Faraday.new(endpoint.to_s, connection_options)
152
+ end
153
+
154
+ # Return a Hash of connection options to initiate the Faraday::Connection with
155
+ #
156
+ # @return [Hash]
157
+ def connection_options
158
+ @connection_options ||= {
159
+ builder: middleware,
160
+ headers: {
161
+ accept: "application/json",
162
+ user_agent: user_agent
163
+ },
164
+ request: {
165
+ open_timeout: 5,
166
+ timeout: 10
167
+ }
168
+ }
169
+ end
170
+
171
+ # Return a Faraday middleware stack to initiate the Faraday::Connection with
172
+ #
173
+ # @see http://mislav.uniqpath.com/2011/07/faraday-advanced-http/
174
+ def middleware
175
+ @middleware ||= Faraday::RackBuilder.new do |builder|
176
+ # Convert request params to "www-form-urlencoded"
177
+ builder.use Faraday::Request::UrlEncoded
178
+
179
+ # Parse JSON response bodies
180
+ builder.use Ably::Rest::Middleware::ParseJson
181
+
182
+ # Log HTTP requests if debug_http option set
183
+ builder.response :logger if @debug_http
184
+
185
+ # Raise exceptions if response code is invalid
186
+ builder.use Ably::Rest::Middleware::Exceptions
187
+
188
+ # Set Faraday's HTTP adapter
189
+ builder.adapter Faraday.default_adapter
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end