spotify_web 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,162 @@
1
+ require 'faye/websocket'
2
+ require 'em-http'
3
+ require 'json'
4
+
5
+ require 'spotify_web/assertions'
6
+ require 'spotify_web/loggable'
7
+ require 'spotify_web/schema/mercury.pb'
8
+
9
+ module SpotifyWeb
10
+ # Represents the interface for sending and receiving data in Spotify
11
+ # @api private
12
+ class Connection
13
+ include Assertions
14
+ include Loggable
15
+
16
+ # Maps method actions to their Spotify identifier
17
+ METHODS = {'SUB' => 1, 'UNSUB' => 2}
18
+
19
+ # The host that this connection is bound to
20
+ # @return [String]
21
+ attr_reader :host
22
+
23
+ # The callback to run when a message is received from the underlying socket.
24
+ # The data passed to the callback will always be a hash.
25
+ # @return [Proc]
26
+ attr_accessor :handler
27
+
28
+ # Builds a new connection for sending / receiving data via the given host.
29
+ #
30
+ # @note This will *not* open the connection -- #start must be explicitly called in order to do so.
31
+ # @param [String] host The host to open a conection to
32
+ # @param [Hash] options The connection options
33
+ # @option options [Fixnum] :timeout The amount of time to allow to elapse for requests before timing out
34
+ # @raise [ArgumentError] if an invalid option is specified
35
+ def initialize(host, options = {})
36
+ assert_valid_keys(options, :timeout)
37
+
38
+ @host = host
39
+ @message_id = 0
40
+ @timeout = options[:timeout]
41
+ end
42
+
43
+ # Initiates the connection with Spotify
44
+ #
45
+ # @return [true]
46
+ def start
47
+ uri = URI.parse("ws://#{host}")
48
+ scheme = uri.port == 443 ? 'wss' : 'ws'
49
+ @socket = Faye::WebSocket::Client.new("#{scheme}://#{uri.host}")
50
+ @socket.onopen = lambda {|event| on_open(event)}
51
+ @socket.onclose = lambda {|event| on_close(event)}
52
+ @socket.onmessage = lambda {|event| on_message(event)}
53
+ true
54
+ end
55
+
56
+ # Closes the connection (if one was previously opened)
57
+ #
58
+ # @return [true]
59
+ def close
60
+ if @socket
61
+ @socket.close
62
+
63
+ # Spotify doesn't send the disconnect frame quickly, so the callback
64
+ # gets run immediately
65
+ EventMachine.add_timer(0.1) { on_close(nil) }
66
+ end
67
+ true
68
+ end
69
+
70
+ # Whether this connection's socket is currently open
71
+ #
72
+ # @return [Boolean] +true+ if the connection is open, otherwise +false+
73
+ def connected?
74
+ @connected
75
+ end
76
+
77
+ # Publishes the given params to the underlying web socket. The defaults
78
+ # initially configured as part of the connection will also be included in
79
+ # the message.
80
+ #
81
+ # @param [Hash] params The parameters to include in the message sent
82
+ # @return [Fixnum] The id of the message delivered
83
+ def publish(command, options)
84
+ if command == 'request'
85
+ options = {:uri => '', :method => 'GET', :source => ''}.merge(options)
86
+ options[:content_type] = 'vnd.spotify/mercury-mget-request' if options[:payload].is_a?(Schema::Mercury::MercuryMultiGetRequest)
87
+ payload = options.delete(:payload)
88
+
89
+ # Generate arguments for the request
90
+ args = [
91
+ METHODS[options[:method]] || 0,
92
+ Base64.encode64(Schema::Mercury::MercuryRequest.new(options).encode)
93
+ ]
94
+ args << Base64.encode64(payload.encode) if payload
95
+
96
+ # Update the command to what Spotify expects
97
+ command = 'sp/hm_b64'
98
+ else
99
+ args = options
100
+ end
101
+
102
+ message = {
103
+ :id => next_message_id,
104
+ :name => command,
105
+ :args => args || []
106
+ }
107
+
108
+ logger.debug "Message sent: #{message.inspect}"
109
+ @socket.send(message.to_json)
110
+
111
+ # Add timeout handler
112
+ EventMachine.add_timer(@timeout) do
113
+ dispatch('id' => message[:id], 'command' => 'response_received', 'error' => 'timed out')
114
+ end if @timeout
115
+
116
+ message[:id]
117
+ end
118
+
119
+ private
120
+ # Runs the configured handler with the given message
121
+ def dispatch(message)
122
+ SpotifyWeb.run { @handler.call(message) } if @handler
123
+ end
124
+
125
+ # Callback when the socket is opened.
126
+ def on_open(event)
127
+ logger.debug 'Socket opened'
128
+ @connected = true
129
+ dispatch('command' => 'session_started')
130
+ end
131
+
132
+ # Callback when the socket is closed. This will mark the connection as no
133
+ # longer connected.
134
+ def on_close(event)
135
+ logger.debug 'Socket closed'
136
+ @connected = false
137
+ @socket = nil
138
+ dispatch('command' => 'session_ended')
139
+ end
140
+
141
+ # Callback when a message has been received from the remote server on the
142
+ # open socket.
143
+ def on_message(event)
144
+ message = JSON.parse(event.data)
145
+
146
+ if message['id']
147
+ message['command'] = 'response_received'
148
+ elsif message['message']
149
+ command, *args = *message['message']
150
+ message = {'command' => command, 'args' => args}
151
+ end
152
+
153
+ logger.debug "Message received: #{message.inspect}"
154
+ dispatch(message)
155
+ end
156
+
157
+ # Calculates what the next message id should be sent to Spotify
158
+ def next_message_id
159
+ @message_id += 1
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,13 @@
1
+ module SpotifyWeb
2
+ # Represents an error within the library
3
+ class Error < StandardError
4
+ end
5
+
6
+ # Represents an error that occurred while connecting to the Spotify Web API
7
+ class ConnectionError < Error
8
+ end
9
+
10
+ # Represents an error that occurred while interacting with the Spotify Web API
11
+ class APIError < Error
12
+ end
13
+ end
@@ -0,0 +1,95 @@
1
+ module SpotifyWeb
2
+ # Provides access to all of the events that get triggered by incoming messages
3
+ # from the Spotify Web API
4
+ # @api private
5
+ class Event
6
+ class << self
7
+ # Maps Spotify command => event name
8
+ # @return [Hash<String, String>]
9
+ attr_reader :commands
10
+
11
+ # Defines a new event that maps to the given Spotify command. The
12
+ # block defines how to typecast the data that is received from Spotify.
13
+ #
14
+ # @param [String] name The name of the event exposed to the rest of the library
15
+ # @param [String] command The Spotify command that this event name maps to
16
+ # @yield [data] Gives the data to typecast to the block
17
+ # @yieldparam [Hash] data The data received from Spotify
18
+ # @yieldreturn The typecasted data that should be passed into any handlers bound to the event
19
+ # @return [nil]
20
+ def handle(name, command = name, &block)
21
+ block ||= lambda { [args] }
22
+ commands[command] = name
23
+
24
+ define_method("typecast_#{command}_event", &block)
25
+ protected :"typecast_#{command}_event"
26
+ end
27
+
28
+ # Determines whether the given command is handled.
29
+ #
30
+ # @param [String] command The command to check for the existence of
31
+ # @return [Boolean] +true+ if the command exists, otherwise +false+
32
+ def command?(command)
33
+ commands.include?(command)
34
+ end
35
+ end
36
+
37
+ @commands = {}
38
+
39
+ # The client's connection has opened
40
+ handle :session_started
41
+
42
+ # The client's connection has closed
43
+ handle :session_ended
44
+
45
+ # The user is successfully logged in
46
+ handle :session_authenticated, :login_complete
47
+
48
+ # The client re-connected after previously being disconnected
49
+ handle :reconnected
50
+
51
+ # A response was receivied from a prior command sent to Spotify
52
+ handle :response_received do
53
+ data
54
+ end
55
+
56
+ # A request was made to evaluate javascript on the client
57
+ handle :work_requested, :do_work do
58
+ data
59
+ end
60
+
61
+ # The name of the event that was triggered
62
+ # @return [String]
63
+ attr_reader :name
64
+
65
+ # The raw arguments list from the event
66
+ # @return [Array<Object>]
67
+ attr_reader :args
68
+
69
+ # The raw hash of data parsed from the event
70
+ # @return [Hash<String, Object>]
71
+ attr_reader :data
72
+
73
+ # The typecasted results args parsed from the event
74
+ # @return [Array<Array<Object>>]
75
+ attr_reader :results
76
+
77
+ # Creates a new event triggered with the given data
78
+ #
79
+ # @param [SpotifyWeb::Client] client The client that this event is bound to
80
+ # @param [Symbol] command The name of the command that fired the event
81
+ # @param [Array] args The raw argument data from the event
82
+ def initialize(client, command, args)
83
+ @client = client
84
+ @args = args
85
+ @data = args[0]
86
+ @name = self.class.commands[command]
87
+ @results = __send__("typecast_#{command}_event")
88
+ @results = [[@results].compact] unless @results.is_a?(Array)
89
+ end
90
+
91
+ private
92
+ # The client that all APIs filter through
93
+ attr_reader :client
94
+ end
95
+ end
@@ -0,0 +1,74 @@
1
+ require 'spotify_web/assertions'
2
+ require 'spotify_web/event'
3
+ require 'spotify_web/loggable'
4
+
5
+ module SpotifyWeb
6
+ # Represents a callback that's been bound to a particular event
7
+ # @api private
8
+ class Handler
9
+ include Assertions
10
+ include Loggable
11
+
12
+ # The event this handler is bound to
13
+ # @return [String]
14
+ attr_reader :event
15
+
16
+ # Whether to only call the handler once and then never again
17
+ # @return [Boolean] +true+ if only called once, otherwise +false+
18
+ attr_reader :once
19
+
20
+ # The data that must be matched in order for the handler to run
21
+ # @return [Hash<String, Object>]
22
+ attr_reader :conditions
23
+
24
+ # Builds a new handler bound to the given event.
25
+ #
26
+ # @param [String] event The name of the event to bind to
27
+ # @param [Hash] options The configuration options
28
+ # @option options [Boolean] :once (false) Whether to only call the handler once
29
+ # @option options [Hash] :if (nil) Data that must be matched to run
30
+ # @raise [ArgumentError] if an invalid option is specified
31
+ def initialize(event, options = {}, &block)
32
+ assert_valid_values(event, *Event.commands.values)
33
+ assert_valid_keys(options, :once, :if)
34
+ options = {:once => false, :if => nil}.merge(options)
35
+
36
+ @event = event
37
+ @once = options[:once]
38
+ @conditions = options[:if]
39
+ @block = block
40
+ end
41
+
42
+ # Runs this handler for each result from the given event.
43
+ #
44
+ # @param [SpotifyWeb::Event] event The event being triggered
45
+ # @return [Boolean] +true+ if conditions were matched to run the handler, otherwise +false+
46
+ def run(event)
47
+ if conditions_match?(event.data)
48
+ # Run the block for each individual result
49
+ event.results.each do |args|
50
+ begin
51
+ @block.call(*args)
52
+ rescue StandardError => ex
53
+ logger.error(([ex.message] + ex.backtrace) * "\n")
54
+ end
55
+ end
56
+
57
+ true
58
+ else
59
+ false
60
+ end
61
+ end
62
+
63
+ private
64
+ # Determines whether the conditions configured for this handler match the
65
+ # event data
66
+ def conditions_match?(data)
67
+ if conditions
68
+ conditions.all? {|(key, value)| data[key] == value}
69
+ else
70
+ true
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,13 @@
1
+ module SpotifyWeb
2
+ # Provides a set of helper methods for logging
3
+ # @api private
4
+ module Loggable
5
+ private
6
+ # Delegates access to the logger to SpotifyWeb.logger
7
+ #
8
+ # @return [Logger] The logger configured for this library
9
+ def logger
10
+ SpotifyWeb.logger
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,48 @@
1
+ require 'spotify_web/resource'
2
+ require 'spotify_web/resource_collection'
3
+ require 'spotify_web/song'
4
+ require 'spotify_web/schema/mercury.pb'
5
+ require 'spotify_web/schema/playlist4.pb'
6
+
7
+ module SpotifyWeb
8
+ # Represents a collection of songs
9
+ class Playlist < Resource
10
+ # The user this playlist is managed by
11
+ # @return [SpotifyWeb::User]
12
+ attribute :user, :load => false
13
+
14
+ # The human-readable name for the playlist
15
+ # @return [String]
16
+ attribute :name
17
+
18
+ # The songs that have been added to this playlist
19
+ # @return [Array<SpotifyWeb::Song>]
20
+ attribute :songs do |songs|
21
+ ResourceCollection.new(client, songs.map {|song| Song.new(client, :uri => song.uri)})
22
+ end
23
+
24
+ def name #:nodoc:
25
+ uri_id == 'starred' ? 'Starred' : @name
26
+ end
27
+
28
+ def uri_id #:nodoc:
29
+ @uri_id ||= @uri ? @uri.split(':')[4] : super
30
+ end
31
+
32
+ # Loads the attributes for this playlist
33
+ def load
34
+ path = uri_id == 'starred' ? uri_id : "playlist/#{uri_id}"
35
+ response = api('request',
36
+ :uri => "hm://playlist/user/#{user.username}/#{path}?from=0&length=100",
37
+ :response_schema => Schema::Playlist4::SelectedListContent
38
+ )
39
+ result = response['result']
40
+
41
+ attributes = result.attributes.to_hash
42
+ attributes[:songs] = result.contents.items
43
+ self.attributes = attributes
44
+
45
+ super
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,309 @@
1
+ require 'pp'
2
+ require 'radix'
3
+ require 'spotify_web/assertions'
4
+ require 'spotify_web/error'
5
+
6
+ module SpotifyWeb
7
+ # Represents an object that's been created using content from Spotify. This
8
+ # encapsulates responsibilities such as reading and writing attributes.
9
+ class Resource
10
+ include Assertions
11
+
12
+ class << self
13
+ include Assertions
14
+
15
+ # Defines a new Spotify attribute on this class. By default, the name
16
+ # of the attribute is assumed to be the same name that Spotify specifies
17
+ # in its API. If the names are different, this can be overridden on a
18
+ # per-attribute basis.
19
+ #
20
+ # @api private
21
+ # @param [String] name The name for the attribute
22
+ # @param [Hash] options The configuration options
23
+ # @option options [Boolean] :load (true) Whether the resource should be loaded remotely from Spotify in order to access the attribute
24
+ # @raise [ArgumentError] if an invalid option is specified
25
+ # @example
26
+ # # Define a "name" attribute that maps to a Spotify "name" attribute
27
+ # attribute :name
28
+ #
29
+ # # Define an "id" attribute that maps to a Spotify "_id" attribute
30
+ # attribute :id, :_id
31
+ #
32
+ # # Define an "user_id" attribute that maps to both a Spotify "user_id" and "userid" attribute
33
+ # attribute :user_id, :user_id, :userid
34
+ #
35
+ # # Define a "time" attribute that maps to a Spotify "time" attribute
36
+ # # and converts the value to a Time object
37
+ # attribute :time do |value|
38
+ # Time.at(value)
39
+ # end
40
+ #
41
+ # # Define a "created_at" attribute that maps to a Spotify "time" attribute
42
+ # # and converts the value to a Time object
43
+ # attribute :created_at, :time do |value|
44
+ # Time.at(value)
45
+ # end
46
+ #
47
+ # # Define a "songs" attribute that does *not* get loaded from Spotify
48
+ # # when accessed
49
+ # attribute :songs, :load => false
50
+ #
51
+ # @!macro [attach] attribute
52
+ # @!attribute [r] $1
53
+ def attribute(name, *spotify_names, &block)
54
+ options = spotify_names.last.is_a?(Hash) ? spotify_names.pop : {}
55
+ assert_valid_keys(options, :load)
56
+ options = {:load => true}.merge(options)
57
+
58
+ # Reader
59
+ define_method(name) do
60
+ load if instance_variable_get("@#{name}").nil? && !loaded? && options[:load]
61
+ instance_variable_get("@#{name}")
62
+ end
63
+
64
+ # Query
65
+ define_method("#{name}?") do
66
+ !!__send__(name)
67
+ end
68
+
69
+ # Typecasting
70
+ block ||= lambda do |value|
71
+ value.force_encoding('UTF-8') if value.is_a?(String)
72
+ value
73
+ end
74
+ define_method("typecast_#{name}", &block)
75
+ protected :"typecast_#{name}"
76
+
77
+ # Attribute name conversion
78
+ spotify_names = [name] if spotify_names.empty?
79
+ spotify_names.each do |spotify_name|
80
+ define_method("#{spotify_name}=") do |value|
81
+ instance_variable_set("@#{name}", value.nil? ? nil : __send__("typecast_#{name}", value))
82
+ end
83
+ protected :"#{spotify_name}="
84
+ end
85
+ end
86
+
87
+ # The Spotify type name for this resouce
88
+ # @return [String]
89
+ attr_accessor :resource_name
90
+
91
+ # The metadata schema to use for loading data for this resource
92
+ # @return [Class]
93
+ attr_accessor :metadata_schema
94
+
95
+ # Provides a default resource name if one isn't specified
96
+ # @return [String]
97
+ def resource_name
98
+ @resource_name ||= begin
99
+ result = name.split('::').last.downcase
100
+ result.gsub!(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2')
101
+ result.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
102
+ result
103
+ end
104
+ end
105
+ end
106
+
107
+ # The characters to encoding / decoding in base62
108
+ BASE62_CHARS = ('0'..'9').to_a + ('a'..'z').to_a + ('A'..'Z').to_a
109
+
110
+ # The unique id for this resource on Spotify
111
+ # "e1987c10dbc34f4d8be1b11ddfd6bb31"
112
+ # @return [String]
113
+ attribute :id, :load => false
114
+
115
+ # The global unique id for this resource on Spotify
116
+ # "\xE1\x98|\x10\xDB\xC3OM\x8B\xE1\xB1\x1D\xDF\xD6\xBB1"
117
+ # @return [String]
118
+ attribute :gid, :load => false
119
+
120
+ # The URI for loading information about this resource
121
+ # "spotify:track:6RGXtkDeWNP2gyASlfjzTr"
122
+ # @return [String]
123
+ attribute :uri, :load => false
124
+
125
+ # The id used within the URI for this resource
126
+ # "6RGXtkDeWNP2gyASlfjzTr"
127
+ # @return [String]
128
+ attribute :uri_id, :load => false
129
+
130
+ # Initializes this resources with the given attributes. This will continue
131
+ # to call the superclass's constructor with any additional arguments that
132
+ # get specified.
133
+ #
134
+ # @api private
135
+ def initialize(client, attributes = {}, *args)
136
+ @loaded = false
137
+ @metadata_loaded = false
138
+ @client = client
139
+ self.attributes = attributes
140
+ super(*args)
141
+ end
142
+
143
+ # The unique identifier, represented in base16
144
+ #
145
+ # @return [String] The Spotify ID for the resource
146
+ def id
147
+ @id ||= begin
148
+ if @gid
149
+ Radix::Base.new(Radix::BASE::HEX).encode(gid).rjust(32, '0')
150
+ elsif @uri
151
+ Radix::Base.new(Radix::BASE::HEX).convert(uri_id, BASE62_CHARS).rjust(32, '0')
152
+ end
153
+ end
154
+ end
155
+
156
+ # The unique group identifier, represented as bytes in base 16
157
+ #
158
+ # @example "\xE1\x98|\x10\xDB\xC3OM\x8B\xE1\xB1\x1D\xDF\xD6\xBB1"
159
+ # @return [String] The group id
160
+ def gid
161
+ @gid ||= id && Radix::Base.new(Radix::BASE::HEX).decode(id)
162
+ end
163
+
164
+ # The unique URI representing this resource in Spotify
165
+ #
166
+ # @example "spotify:track:6RGXtkDeWNP2gyASlfjzTr"
167
+ # @return [String] The URI for the resource
168
+ def uri
169
+ @uri ||= uri_id && "spotify:#{self.class.resource_name}:#{uri_id}"
170
+ end
171
+
172
+ # The id used within the resource's URI, represented in base62.
173
+ #
174
+ # @example "6RGXtkDeWNP2gyASlfjzTr"
175
+ # @return [String] The URI's id
176
+ def uri_id
177
+ @uri_id ||= begin
178
+ if @uri
179
+ @uri.split(':')[2]
180
+ elsif @gid || @id
181
+ Radix::Base.new(BASE62_CHARS).convert(id, Radix::BASE::HEX).rjust(22, '0')
182
+ end
183
+ end
184
+ end
185
+
186
+ # Loads the attributes for this resource from Spotify. By default this is
187
+ # a no-op and just marks the resource as loaded.
188
+ #
189
+ # @return [true]
190
+ def load
191
+ load_metadata
192
+ @loaded = true
193
+ end
194
+ alias :reload :load
195
+
196
+ # Determines whether the current resource has been loaded from Spotify.
197
+ #
198
+ # @return [Boolean] +true+ if the resource has been loaded, otherwise +false+
199
+ def loaded?
200
+ @loaded
201
+ end
202
+
203
+ # Looks up the metadata associated with this resource.
204
+ #
205
+ # @api private
206
+ # @return [Object, nil] +nil+ if there is no metadata schema associated, otherwise the result of the request
207
+ def load_metadata
208
+ if self.class.metadata_schema && !@metadata_loaded
209
+ if @metadata_loader
210
+ # Load the metadata externally
211
+ @metadata_loader.call
212
+ @metadata_loader = nil
213
+ else
214
+ # Load the metadata just for this single resource
215
+ response = api('request',
216
+ :uri => metadata_uri,
217
+ :response_schema => self.class.metadata_schema
218
+ )
219
+ self.metadata = response['result']
220
+ end
221
+ end
222
+ end
223
+
224
+ # Updates this resource based on the given metadata
225
+ #
226
+ # @api private
227
+ # @param [Beefcake::Message, Proc] metadata The metadata to use or a proc for loading it
228
+ def metadata=(metadata)
229
+ if !metadata || metadata.is_a?(Proc)
230
+ @metadata_loader = metadata
231
+ else
232
+ @metadata_loaded = true
233
+ self.attributes = metadata.to_hash
234
+ end
235
+ end
236
+
237
+ # The URI for looking up the resource's metadata
238
+ #
239
+ # @api private
240
+ # @return [String, nil] +nil+ if there is no metadata schema associated, otherwise the uri
241
+ def metadata_uri
242
+ if self.class.metadata_schema
243
+ "hm://metadata/#{self.class.resource_name}/#{id}"
244
+ end
245
+ end
246
+
247
+ # Attempts to set attributes on the object only if they've been explicitly
248
+ # defined by the class.
249
+ #
250
+ # @api private
251
+ # @param [Hash] attributes The updated attributes for the resource
252
+ def attributes=(attributes)
253
+ if attributes
254
+ attributes.each do |attribute, value|
255
+ attribute = attribute.to_s
256
+ __send__("#{attribute}=", value) if respond_to?("#{attribute}=", true)
257
+ end
258
+ end
259
+ end
260
+
261
+ # Forces this object to use PP's implementation of inspection.
262
+ #
263
+ # @api private
264
+ # @return [String]
265
+ def pretty_print(q)
266
+ q.pp_object(self)
267
+ end
268
+ alias inspect pretty_print_inspect
269
+
270
+ # Defines the instance variables that should be printed when inspecting this
271
+ # object. This ignores the +@client+ and +@loaded+ variables.
272
+ #
273
+ # @api private
274
+ # @return [Array<Symbol>]
275
+ def pretty_print_instance_variables
276
+ (instance_variables - [:'@client', :'@loaded', :'@metadata_loaded', :'@metadata_loader']).sort
277
+ end
278
+
279
+ # Determines whether this resource is equal to another based on their
280
+ # unique identifiers.
281
+ #
282
+ # @param [Object] other The object this resource is being compared against
283
+ # @return [Boolean] +true+ if the resource ids are equal, otherwise +false+
284
+ def ==(other)
285
+ if other && other.respond_to?(:id) && other.id
286
+ other.id == id
287
+ else
288
+ false
289
+ end
290
+ end
291
+ alias :eql? :==
292
+
293
+ # Generates a hash for this resource based on the unique identifier
294
+ #
295
+ # @return [Fixnum]
296
+ def hash
297
+ id.hash
298
+ end
299
+
300
+ private
301
+ # The client that all APIs filter through
302
+ attr_reader :client
303
+
304
+ # Runs the given API command on the client.
305
+ def api(command, options = {})
306
+ client.api(command, options)
307
+ end
308
+ end
309
+ end