sockudo 1.0.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.
@@ -0,0 +1,593 @@
1
+ require 'base64'
2
+ require 'securerandom'
3
+ require 'pusher-signature'
4
+
5
+ module Sockudo
6
+ class Client
7
+ attr_accessor :scheme, :host, :port, :app_id, :key, :secret, :encryption_master_key
8
+ attr_reader :http_proxy, :proxy, :base_id, :publish_serial
9
+ attr_writer :connect_timeout, :send_timeout, :receive_timeout,
10
+ :keep_alive_timeout
11
+
12
+ ## CONFIGURATION ##
13
+ DEFAULT_CONNECT_TIMEOUT = 5
14
+ DEFAULT_SEND_TIMEOUT = 5
15
+ DEFAULT_RECEIVE_TIMEOUT = 5
16
+ DEFAULT_KEEP_ALIVE_TIMEOUT = 30
17
+ DEFAULT_CLUSTER = "mt1"
18
+
19
+ # Loads the configuration from an url in the environment
20
+ def self.from_env(key = 'SOCKUDO_URL')
21
+ url = ENV[key] || raise(ConfigurationError, key)
22
+ from_url(url)
23
+ end
24
+
25
+ # Loads the configuration from a url
26
+ def self.from_url(url)
27
+ client = new
28
+ client.url = url
29
+ client
30
+ end
31
+
32
+ def initialize(options = {})
33
+ @scheme = "https"
34
+ @port = options[:port] || 443
35
+
36
+ if options.key?(:encrypted)
37
+ warn "[DEPRECATION] `encrypted` is deprecated and will be removed in the next major version. Use `use_tls` instead."
38
+ end
39
+
40
+ if options[:use_tls] == false || options[:encrypted] == false
41
+ @scheme = "http"
42
+ @port = options[:port] || 80
43
+ end
44
+
45
+ @app_id = options[:app_id]
46
+ @key = options[:key]
47
+ @secret = options[:secret]
48
+ @auto_idempotency_key = options.fetch(:auto_idempotency_key, true)
49
+ @base_id = Base64.urlsafe_encode64(SecureRandom.random_bytes(12), padding: false)
50
+ @publish_serial = 0
51
+ @max_retries = 3
52
+
53
+ @host = options[:host]
54
+ @host ||= "api-#{options[:cluster]}.sockudo.com" unless options[:cluster].nil? || options[:cluster].empty?
55
+ @host ||= "api-#{DEFAULT_CLUSTER}.sockudo.com"
56
+
57
+ @encryption_master_key = Base64.strict_decode64(options[:encryption_master_key_base64]) if options[:encryption_master_key_base64]
58
+
59
+ @http_proxy = options[:http_proxy]
60
+
61
+ # Default timeouts
62
+ @connect_timeout = DEFAULT_CONNECT_TIMEOUT
63
+ @send_timeout = DEFAULT_SEND_TIMEOUT
64
+ @receive_timeout = DEFAULT_RECEIVE_TIMEOUT
65
+ @keep_alive_timeout = DEFAULT_KEEP_ALIVE_TIMEOUT
66
+ end
67
+
68
+ # @private Returns the authentication token for the client
69
+ def authentication_token
70
+ raise ConfigurationError, :key unless @key
71
+ raise ConfigurationError, :secret unless @secret
72
+ Sockudo::Signature::Token.new(@key, @secret)
73
+ end
74
+
75
+ # @private Builds a url for this app, optionally appending a path
76
+ def url(path = nil)
77
+ raise ConfigurationError, :app_id unless @app_id
78
+ URI::Generic.build({
79
+ scheme: @scheme,
80
+ host: @host,
81
+ port: @port,
82
+ path: "/apps/#{@app_id}#{path}"
83
+ })
84
+ end
85
+
86
+ # Configure Sockudo connection by providing a url rather than specifying
87
+ # scheme, key, secret, and app_id separately.
88
+ #
89
+ # @example
90
+ # Sockudo.url = http://KEY:SECRET@localhost/apps/APP_ID
91
+ #
92
+ def url=(url)
93
+ uri = URI.parse(url)
94
+ @scheme = uri.scheme
95
+ @app_id = uri.path.split('/').last
96
+ @key = uri.user
97
+ @secret = uri.password
98
+ @host = uri.host
99
+ @port = uri.port
100
+ end
101
+
102
+ def http_proxy=(http_proxy)
103
+ @http_proxy = http_proxy
104
+ uri = URI.parse(http_proxy)
105
+ @proxy = {
106
+ scheme: uri.scheme,
107
+ host: uri.host,
108
+ port: uri.port,
109
+ user: uri.user,
110
+ password: uri.password
111
+ }
112
+ end
113
+
114
+ # Configure whether Sockudo API calls should be made over SSL
115
+ # (default false)
116
+ #
117
+ # @example
118
+ # Sockudo.encrypted = true
119
+ #
120
+ def encrypted=(boolean)
121
+ @scheme = boolean ? 'https' : 'http'
122
+ # Configure port if it hasn't already been configured
123
+ @port = boolean ? 443 : 80
124
+ end
125
+
126
+ def encrypted?
127
+ @scheme == 'https'
128
+ end
129
+
130
+ def cluster=(cluster)
131
+ cluster = DEFAULT_CLUSTER if cluster.nil? || cluster.empty?
132
+
133
+ @host = "api-#{cluster}.sockudo.com"
134
+ end
135
+
136
+ # Convenience method to set all timeouts to the same value (in seconds).
137
+ # For more control, use the individual writers.
138
+ def timeout=(value)
139
+ @connect_timeout, @send_timeout, @receive_timeout = value, value, value
140
+ end
141
+
142
+ # Set an encryption_master_key to use with private-encrypted channels from
143
+ # a base64 encoded string.
144
+ def encryption_master_key_base64=(s)
145
+ @encryption_master_key = s ? Base64.strict_decode64(s) : nil
146
+ end
147
+
148
+ ## INTERACT WITH THE API ##
149
+
150
+ def resource(path)
151
+ Resource.new(self, path)
152
+ end
153
+
154
+ # GET arbitrary REST API resource using a synchronous http client.
155
+ # All request signing is handled automatically.
156
+ #
157
+ # @example
158
+ # begin
159
+ # Sockudo.get('/channels', filter_by_prefix: 'private-')
160
+ # rescue Sockudo::Error => e
161
+ # # Handle error
162
+ # end
163
+ #
164
+ # @param path [String] Path excluding /apps/APP_ID
165
+ # @param params [Hash] API params (see http://sockudo.com/docs/rest_api)
166
+ #
167
+ # @return [Hash] See Sockudo API docs
168
+ #
169
+ # @raise [Sockudo::Error] Unsuccessful response - see the error message
170
+ # @raise [Sockudo::HTTPError] Error raised inside http client. The original error is wrapped in error.original_error
171
+ #
172
+ def get(path, params = {})
173
+ resource(path).get(params)
174
+ end
175
+
176
+ # GET arbitrary REST API resource using an asynchronous http client.
177
+ # All request signing is handled automatically.
178
+ #
179
+ # When the eventmachine reactor is running, the em-http-request gem is used;
180
+ # otherwise an async request is made using httpclient. See README for
181
+ # details and examples.
182
+ #
183
+ # @param path [String] Path excluding /apps/APP_ID
184
+ # @param params [Hash] API params (see http://sockudo.com/docs/rest_api)
185
+ #
186
+ # @return Either an EM::DefaultDeferrable or a HTTPClient::Connection
187
+ #
188
+ def get_async(path, params = {})
189
+ resource(path).get_async(params)
190
+ end
191
+
192
+ # POST arbitrary REST API resource using a synchronous http client.
193
+ # Works identially to get method, but posts params as JSON in post body.
194
+ def post(path, params = {}, headers = {})
195
+ resource(path).post(params, headers)
196
+ end
197
+
198
+ # POST arbitrary REST API resource using an asynchronous http client.
199
+ # Works identially to get_async method, but posts params as JSON in post
200
+ # body.
201
+ def post_async(path, params = {}, headers = {})
202
+ resource(path).post_async(params, headers)
203
+ end
204
+
205
+ ## HELPER METHODS ##
206
+
207
+ # Convenience method for creating a new WebHook instance for validating
208
+ # and extracting info from a received WebHook
209
+ #
210
+ # @param request [Rack::Request] Either a Rack::Request or a Hash containing :key, :signature, :body, and optionally :content_type.
211
+ #
212
+ def webhook(request)
213
+ WebHook.new(request, self)
214
+ end
215
+
216
+ # Return a convenience channel object by name that delegates operations
217
+ # on a channel. No API request is made.
218
+ #
219
+ # @example
220
+ # Sockudo['my-channel']
221
+ # @return [Channel]
222
+ # @raise [Sockudo::Error] if the channel name is invalid.
223
+ # Channel names should be less than 200 characters, and
224
+ # should not contain anything other than letters, numbers, or the
225
+ # characters "_\-=@,.;"
226
+ def channel(channel_name)
227
+ Channel.new(nil, channel_name, self)
228
+ end
229
+
230
+ alias :[] :channel
231
+
232
+ # Request a list of occupied channels from the API
233
+ #
234
+ # GET /apps/[id]/channels
235
+ #
236
+ # @param params [Hash] Hash of parameters for the API - see REST API docs
237
+ #
238
+ # @return [Hash] See Sockudo API docs
239
+ #
240
+ # @raise [Sockudo::Error] Unsuccessful response - see the error message
241
+ # @raise [Sockudo::HTTPError] Error raised inside http client. The original error is wrapped in error.original_error
242
+ #
243
+ def channels(params = {})
244
+ get('/channels', params)
245
+ end
246
+
247
+ # Request info for a specific channel
248
+ #
249
+ # GET /apps/[id]/channels/[channel_name]
250
+ #
251
+ # @param channel_name [String] Channel name (max 200 characters)
252
+ # @param params [Hash] Hash of parameters for the API - see REST API docs
253
+ #
254
+ # @return [Hash] See Sockudo API docs
255
+ #
256
+ # @raise [Sockudo::Error] Unsuccessful response - see the error message
257
+ # @raise [Sockudo::HTTPError] Error raised inside http client. The original error is wrapped in error.original_error
258
+ #
259
+ def channel_info(channel_name, params = {})
260
+ get("/channels/#{channel_name}", params)
261
+ end
262
+
263
+ # Request info for users of a presence channel
264
+ #
265
+ # GET /apps/[id]/channels/[channel_name]/users
266
+ #
267
+ # @param channel_name [String] Channel name (max 200 characters)
268
+ # @param params [Hash] Hash of parameters for the API - see REST API docs
269
+ #
270
+ # @return [Hash] See Sockudo API docs
271
+ #
272
+ # @raise [Sockudo::Error] Unsuccessful response - see the error message
273
+ # @raise [Sockudo::HTTPError] Error raised inside http client. The original error is wrapped in error.original_error
274
+ #
275
+ def channel_users(channel_name, params = {})
276
+ get("/channels/#{channel_name}/users", params)
277
+ end
278
+
279
+ # Trigger an event on one or more channels
280
+ #
281
+ # POST /apps/[app_id]/events
282
+ #
283
+ # @param channels [String or Array] 1-10 channel names
284
+ # @param event_name [String]
285
+ # @param data [Object] Event data to be triggered in javascript.
286
+ # Objects other than strings will be converted to JSON
287
+ # @param params [Hash] Additional parameters to send to api, e.g socket_id.
288
+ # May include :extras => { headers: Hash, ephemeral: Boolean, idempotency_key: String, echo: Boolean }
289
+ #
290
+ # @return [Hash] See Sockudo API docs
291
+ #
292
+ # @raise [Sockudo::Error] Unsuccessful response - see the error message
293
+ # @raise [Sockudo::HTTPError] Error raised inside http client. The original error is wrapped in error.original_error
294
+ #
295
+ def trigger(channels, event_name, data, params = {})
296
+ params = inject_auto_idempotency_key(params)
297
+ body, headers = trigger_params_with_headers(channels, event_name, data, params)
298
+ post_with_retry('/events', body, headers)
299
+ end
300
+
301
+ # Trigger multiple events at the same time
302
+ #
303
+ # POST /apps/[app_id]/batch_events
304
+ #
305
+ # @param events [Array] List of events to publish. Each event hash may
306
+ # include an :idempotency_key field for at-most-once delivery.
307
+ #
308
+ # @return [Hash] See Sockudo API docs
309
+ #
310
+ # @raise [Sockudo::Error] Unsuccessful response - see the error message
311
+ # @raise [Sockudo::HTTPError] Error raised inside http client. The original error is wrapped in error.original_error
312
+ #
313
+ def trigger_batch(*events)
314
+ flat_events = events.flatten
315
+ inject_auto_idempotency_keys_batch!(flat_events)
316
+ post_with_retry('/batch_events', trigger_batch_params(flat_events))
317
+ end
318
+
319
+ # Trigger an event on one or more channels asynchronously.
320
+ # For parameters see #trigger
321
+ #
322
+ def trigger_async(channels, event_name, data, params = {})
323
+ params = inject_auto_idempotency_key(params)
324
+ body, headers = trigger_params_with_headers(channels, event_name, data, params)
325
+ post_async('/events', body, headers)
326
+ end
327
+
328
+ # Trigger multiple events asynchronously.
329
+ # For parameters see #trigger_batch
330
+ #
331
+ def trigger_batch_async(*events)
332
+ flat_events = events.flatten
333
+ inject_auto_idempotency_keys_batch!(flat_events)
334
+ post_async('/batch_events', trigger_batch_params(flat_events))
335
+ end
336
+
337
+
338
+ # Generate the expected response for an authentication endpoint.
339
+ # See https://sockudo.com/docs/channels/server_api/authorizing-users for details.
340
+ #
341
+ # @example Private channels
342
+ # render :json => Sockudo.authenticate('private-my_channel', params[:socket_id])
343
+ #
344
+ # @example Presence channels
345
+ # render :json => Sockudo.authenticate('presence-my_channel', params[:socket_id], {
346
+ # :user_id => current_user.id, # => required
347
+ # :user_info => { # => optional - for example
348
+ # :name => current_user.name,
349
+ # :email => current_user.email
350
+ # }
351
+ # })
352
+ #
353
+ # @param socket_id [String]
354
+ # @param custom_data [Hash] used for example by private channels
355
+ #
356
+ # @return [Hash]
357
+ #
358
+ # @raise [Sockudo::Error] if channel_name or socket_id are invalid
359
+ #
360
+ # @private Custom data is sent to server as JSON-encoded string
361
+ #
362
+ def authenticate(channel_name, socket_id, custom_data = nil)
363
+ channel_instance = channel(channel_name)
364
+ r = channel_instance.authenticate(socket_id, custom_data)
365
+ if channel_name.match(/^private-encrypted-/)
366
+ r[:shared_secret] = Base64.strict_encode64(
367
+ channel_instance.shared_secret(encryption_master_key)
368
+ )
369
+ end
370
+ r
371
+ end
372
+
373
+ # Generate the expected response for a user authentication endpoint.
374
+ # See https://sockudo.com/docs/authenticating_users for details.
375
+ #
376
+ # @example
377
+ # user_data = { id: current_user.id.to_s, company_id: current_user.company_id }
378
+ # render :json => Sockudo.authenticate_user(params[:socket_id], user_data)
379
+ #
380
+ # @param socket_id [String]
381
+ # @param user_data [Hash] user's properties (id is required and must be a string)
382
+ #
383
+ # @return [Hash]
384
+ #
385
+ # @raise [Sockudo::Error] if socket_id or user_data is invalid
386
+ #
387
+ # @private Custom data is sent to server as JSON-encoded string
388
+ #
389
+ def authenticate_user(socket_id, user_data)
390
+ validate_user_data(user_data)
391
+
392
+ custom_data = MultiJson.encode(user_data)
393
+ auth = authentication_string(socket_id, custom_data)
394
+
395
+ { auth: auth, user_data: custom_data }
396
+ end
397
+
398
+ # @private Construct a net/http http client
399
+ def sync_http_client
400
+ require 'httpclient'
401
+
402
+ @client ||= begin
403
+ HTTPClient.new(@http_proxy).tap do |c|
404
+ c.connect_timeout = @connect_timeout
405
+ c.send_timeout = @send_timeout
406
+ c.receive_timeout = @receive_timeout
407
+ c.keep_alive_timeout = @keep_alive_timeout
408
+ end
409
+ end
410
+ end
411
+
412
+ # @private Construct an em-http-request http client
413
+ def em_http_client(uri)
414
+ begin
415
+ unless defined?(EventMachine) && EventMachine.reactor_running?
416
+ raise Error, "In order to use async calling you must be running inside an eventmachine loop"
417
+ end
418
+ require 'em-http' unless defined?(EventMachine::HttpRequest)
419
+
420
+ connection_opts = {
421
+ connect_timeout: @connect_timeout,
422
+ inactivity_timeout: @receive_timeout,
423
+ }
424
+
425
+ if defined?(@proxy)
426
+ proxy_opts = {
427
+ host: @proxy[:host],
428
+ port: @proxy[:port]
429
+ }
430
+ if @proxy[:user]
431
+ proxy_opts[:authorization] = [@proxy[:user], @proxy[:password]]
432
+ end
433
+ connection_opts[:proxy] = proxy_opts
434
+ end
435
+
436
+ EventMachine::HttpRequest.new(uri, connection_opts)
437
+ end
438
+ end
439
+
440
+ private
441
+
442
+ include Sockudo::Utils
443
+
444
+ def inject_auto_idempotency_key(params)
445
+ return params if params.key?(:idempotency_key) || !@auto_idempotency_key
446
+ serial = (@publish_serial += 1)
447
+ params.merge(idempotency_key: "#{@base_id}:#{serial}")
448
+ end
449
+
450
+ def inject_auto_idempotency_keys_batch!(events)
451
+ return false unless @auto_idempotency_key
452
+ serial = (@publish_serial += 1)
453
+ injected = false
454
+ events.each_with_index do |event, index|
455
+ next if event.key?(:idempotency_key)
456
+ event[:idempotency_key] = "#{@base_id}:#{serial}:#{index}"
457
+ injected = true
458
+ end
459
+ injected
460
+ end
461
+
462
+ def post_with_retry(path, body, headers = {})
463
+ last_error = nil
464
+ @max_retries.times do |attempt|
465
+ begin
466
+ return post(path, body, headers)
467
+ rescue Sockudo::HTTPError => e
468
+ last_error = e
469
+ raise unless attempt < @max_retries - 1
470
+ rescue Sockudo::Error => e
471
+ if e.respond_to?(:status) && e.status.is_a?(Integer) && e.status >= 500 && e.status < 600
472
+ last_error = e
473
+ raise unless attempt < @max_retries - 1
474
+ else
475
+ raise
476
+ end
477
+ end
478
+ end
479
+ raise last_error
480
+ end
481
+
482
+ def trigger_params(channels, event_name, data, params)
483
+ channels = Array(channels).map(&:to_s)
484
+ raise Sockudo::Error, "Too many channels (#{channels.length}), max 100" if channels.length > 100
485
+
486
+ encoded_data = if channels.any?{ |c| c.match(/^private-encrypted-/) } then
487
+ raise Sockudo::Error, "Cannot trigger to multiple channels if any are encrypted" if channels.length > 1
488
+ encrypt(channels[0], encode_data(data))
489
+ else
490
+ encode_data(data)
491
+ end
492
+
493
+ params.merge({
494
+ name: event_name,
495
+ channels: channels,
496
+ data: encoded_data,
497
+ })
498
+ end
499
+
500
+ def trigger_params_with_headers(channels, event_name, data, params)
501
+ params = params.dup
502
+ idempotency_key = params.delete(:idempotency_key)
503
+ body = trigger_params(channels, event_name, data, params)
504
+ headers = {}
505
+ if idempotency_key
506
+ body[:idempotency_key] = idempotency_key
507
+ headers['X-Idempotency-Key'] = idempotency_key
508
+ end
509
+ [body, headers]
510
+ end
511
+
512
+ def trigger_batch_params(events)
513
+ {
514
+ batch: events.map do |event|
515
+ event.dup.tap do |e|
516
+ e[:data] = if e[:channel].match(/^private-encrypted-/) then
517
+ encrypt(e[:channel], encode_data(e[:data]))
518
+ else
519
+ encode_data(e[:data])
520
+ end
521
+ end
522
+ end
523
+ }
524
+ end
525
+
526
+ # JSON-encode the data if it's not a string
527
+ def encode_data(data)
528
+ return data if data.is_a? String
529
+ MultiJson.encode(data)
530
+ end
531
+
532
+ # Encrypts a message with a key derived from the master key and channel
533
+ # name
534
+ def encrypt(channel_name, encoded_data)
535
+ raise ConfigurationError, :encryption_master_key unless @encryption_master_key
536
+
537
+ # Only now load rbnacl, so that people that aren't using it don't need to
538
+ # install libsodium
539
+ require_rbnacl
540
+
541
+ secret_box = RbNaCl::SecretBox.new(
542
+ channel(channel_name).shared_secret(@encryption_master_key)
543
+ )
544
+
545
+ nonce = RbNaCl::Random.random_bytes(secret_box.nonce_bytes)
546
+ ciphertext = secret_box.encrypt(nonce, encoded_data)
547
+
548
+ MultiJson.encode({
549
+ "nonce" => Base64::strict_encode64(nonce),
550
+ "ciphertext" => Base64::strict_encode64(ciphertext),
551
+ })
552
+ end
553
+
554
+ def configured?
555
+ host && scheme && key && secret && app_id
556
+ end
557
+
558
+ def require_rbnacl
559
+ require 'rbnacl'
560
+ rescue LoadError => e
561
+ $stderr.puts "You don't have rbnacl installed in your application. Please add it to your Gemfile and run bundle install"
562
+ raise e
563
+ end
564
+
565
+ # Compute authentication string required as part of the user authentication
566
+ # endpoint response. Generally the authenticate method should be used in
567
+ # preference to this one.
568
+ #
569
+ # @param socket_id [String] Each Sockudo socket connection receives a
570
+ # unique socket_id. This is sent from sockudo.js to your server when
571
+ # channel authentication is required.
572
+ # @param custom_string [String] Allows signing additional data
573
+ # @return [String]
574
+ #
575
+ # @raise [Sockudo::Error] if socket_id or custom_string invalid
576
+ #
577
+ def authentication_string(socket_id, custom_string = nil)
578
+ string_to_sign = [socket_id, 'user', custom_string].compact.map(&:to_s).join('::')
579
+
580
+ _authentication_string(socket_id, string_to_sign, authentication_token, string_to_sign)
581
+ end
582
+
583
+ def validate_user_data(user_data)
584
+ return if user_data_valid?(user_data)
585
+
586
+ raise Sockudo::Error, "Invalid user data #{user_data.inspect}"
587
+ end
588
+
589
+ def user_data_valid?(data)
590
+ data.is_a?(Hash) && data.key?(:id) && !data[:id].empty? && data[:id].is_a?(String)
591
+ end
592
+ end
593
+ end
@@ -0,0 +1,112 @@
1
+ require 'pusher-signature'
2
+ require 'digest/md5'
3
+ require 'multi_json'
4
+
5
+ module Sockudo
6
+ class Request
7
+ attr_reader :body, :params
8
+
9
+ def initialize(client, verb, uri, params, body = nil, extra_headers = {})
10
+ @client, @verb, @uri = client, verb, uri
11
+ @head = {
12
+ 'X-Pusher-Library' => 'sockudo-http-ruby ' + Sockudo::VERSION
13
+ }
14
+ @head.merge!(extra_headers) if extra_headers && !extra_headers.empty?
15
+
16
+ @body = body
17
+ if body
18
+ params[:body_md5] = Digest::MD5.hexdigest(body)
19
+ @head['Content-Type'] = 'application/json'
20
+ end
21
+
22
+ request = Sockudo::Signature::Request.new(verb.to_s.upcase, uri.path, params)
23
+ request.sign(client.authentication_token)
24
+ @params = request.signed_params
25
+ end
26
+
27
+ def send_sync
28
+ http = @client.sync_http_client
29
+
30
+ begin
31
+ response = http.request(@verb, @uri, @params, @body, @head)
32
+ rescue HTTPClient::BadResponseError, HTTPClient::TimeoutError,
33
+ SocketError, Errno::ECONNREFUSED => e
34
+ error = Sockudo::HTTPError.new("#{e.message} (#{e.class})")
35
+ error.original_error = e
36
+ raise error
37
+ end
38
+
39
+ body = response.body ? response.body.chomp : nil
40
+
41
+ return handle_response(response.code.to_i, body)
42
+ end
43
+
44
+ def send_async
45
+ if defined?(EventMachine) && EventMachine.reactor_running?
46
+ http_client = @client.em_http_client(@uri)
47
+ df = EM::DefaultDeferrable.new
48
+
49
+ http = case @verb
50
+ when :post
51
+ http_client.post({
52
+ :query => @params, :body => @body, :head => @head
53
+ })
54
+ when :get
55
+ http_client.get({
56
+ :query => @params, :head => @head
57
+ })
58
+ else
59
+ raise "Unsupported verb"
60
+ end
61
+ http.callback {
62
+ begin
63
+ df.succeed(handle_response(http.response_header.status, http.response.chomp))
64
+ rescue => e
65
+ df.fail(e)
66
+ end
67
+ }
68
+ http.errback { |e|
69
+ message = "Network error connecting to sockudo (#{http.error})"
70
+ Sockudo.logger.debug(message)
71
+ df.fail(Error.new(message))
72
+ }
73
+
74
+ return df
75
+ else
76
+ http = @client.sync_http_client
77
+
78
+ return http.request_async(@verb, @uri, @params, @body, @head)
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ def handle_response(status_code, body)
85
+ case status_code
86
+ when 200
87
+ return symbolize_first_level(MultiJson.decode(body))
88
+ when 202
89
+ return body.empty? ? true : symbolize_first_level(MultiJson.decode(body))
90
+ when 400
91
+ raise Error, "Bad request: #{body}"
92
+ when 401
93
+ raise AuthenticationError, body
94
+ when 404
95
+ raise Error, "404 Not found (#{@uri.path})"
96
+ when 407
97
+ raise Error, "Proxy Authentication Required"
98
+ when 413
99
+ raise Error, "Payload Too Large > 10KB"
100
+ else
101
+ raise Error, "Unknown error (status code #{status_code}): #{body}"
102
+ end
103
+ end
104
+
105
+ def symbolize_first_level(hash)
106
+ hash.inject({}) do |result, (key, value)|
107
+ result[key.to_sym] = value
108
+ result
109
+ end
110
+ end
111
+ end
112
+ end