tilia-http 4.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (63) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +19 -0
  3. data/.rspec +1 -0
  4. data/.rubocop.yml +35 -0
  5. data/.simplecov +4 -0
  6. data/.travis.yml +3 -0
  7. data/CHANGELOG.sabre.md +235 -0
  8. data/CONTRIBUTING.md +25 -0
  9. data/Gemfile +18 -0
  10. data/Gemfile.lock +69 -0
  11. data/LICENSE +27 -0
  12. data/LICENSE.sabre +27 -0
  13. data/README.md +68 -0
  14. data/Rakefile +17 -0
  15. data/examples/asyncclient.rb +45 -0
  16. data/examples/basicauth.rb +39 -0
  17. data/examples/client.rb +20 -0
  18. data/examples/reverseproxy.rb +39 -0
  19. data/examples/stringify.rb +37 -0
  20. data/lib/tilia/http/auth/abstract_auth.rb +51 -0
  21. data/lib/tilia/http/auth/aws.rb +191 -0
  22. data/lib/tilia/http/auth/basic.rb +43 -0
  23. data/lib/tilia/http/auth/bearer.rb +37 -0
  24. data/lib/tilia/http/auth/digest.rb +187 -0
  25. data/lib/tilia/http/auth.rb +12 -0
  26. data/lib/tilia/http/client.rb +452 -0
  27. data/lib/tilia/http/client_exception.rb +15 -0
  28. data/lib/tilia/http/client_http_exception.rb +37 -0
  29. data/lib/tilia/http/http_exception.rb +21 -0
  30. data/lib/tilia/http/message.rb +241 -0
  31. data/lib/tilia/http/message_decorator_trait.rb +183 -0
  32. data/lib/tilia/http/message_interface.rb +154 -0
  33. data/lib/tilia/http/request.rb +235 -0
  34. data/lib/tilia/http/request_decorator.rb +160 -0
  35. data/lib/tilia/http/request_interface.rb +126 -0
  36. data/lib/tilia/http/response.rb +164 -0
  37. data/lib/tilia/http/response_decorator.rb +58 -0
  38. data/lib/tilia/http/response_interface.rb +36 -0
  39. data/lib/tilia/http/sapi.rb +165 -0
  40. data/lib/tilia/http/url_util.rb +70 -0
  41. data/lib/tilia/http/util.rb +51 -0
  42. data/lib/tilia/http/version.rb +9 -0
  43. data/lib/tilia/http.rb +416 -0
  44. data/test/http/auth/aws_test.rb +189 -0
  45. data/test/http/auth/basic_test.rb +60 -0
  46. data/test/http/auth/bearer_test.rb +47 -0
  47. data/test/http/auth/digest_test.rb +141 -0
  48. data/test/http/client_mock.rb +101 -0
  49. data/test/http/client_test.rb +331 -0
  50. data/test/http/message_decorator_test.rb +67 -0
  51. data/test/http/message_test.rb +163 -0
  52. data/test/http/request_decorator_test.rb +87 -0
  53. data/test/http/request_test.rb +132 -0
  54. data/test/http/response_decorator_test.rb +28 -0
  55. data/test/http/response_test.rb +38 -0
  56. data/test/http/sapi_mock.rb +12 -0
  57. data/test/http/sapi_test.rb +133 -0
  58. data/test/http/url_util_test.rb +155 -0
  59. data/test/http/util_test.rb +186 -0
  60. data/test/http_test.rb +102 -0
  61. data/test/test_helper.rb +6 -0
  62. data/tilia-http.gemspec +18 -0
  63. metadata +192 -0
@@ -0,0 +1,452 @@
1
+ module Tilia
2
+ module Http
3
+ # A rudimentary HTTP client.
4
+ #
5
+ # This object wraps PHP's curl extension and provides an easy way to send it a
6
+ # Request object, and return a Response object.
7
+ #
8
+ # This is by no means intended as the next best HTTP client, but it does the
9
+ # job and provides a simple integration with the rest of sabre/http.
10
+ #
11
+ # This client emits the following events:
12
+ # before_request(RequestInterface request)
13
+ # after_request(RequestInterface request, ResponseInterface response)
14
+ # error(RequestInterface request, ResponseInterface response, bool &retry, int retry_count)
15
+ # exception(RequestInterface request, ClientException e, bool &retry, int retry_count)
16
+ #
17
+ # The beforeRequest event allows you to do some last minute changes to the
18
+ # request before it's done, such as adding authentication headers.
19
+ #
20
+ # The afterRequest event will be emitted after the request is completed
21
+ # succesfully.
22
+ #
23
+ # If a HTTP error is returned (status code higher than 399) the error event is
24
+ # triggered. It's possible using this event to retry the request, by setting
25
+ # retry to true.
26
+ #
27
+ # The amount of times a request has retried is passed as retry_count, which
28
+ # can be used to avoid retrying indefinitely. The first time the event is
29
+ # called, this will be 0.
30
+ #
31
+ # It's also possible to intercept specific http errors, by subscribing to for
32
+ # example 'error:401'.
33
+ class Client < Tilia::Event::EventEmitter
34
+ protected
35
+
36
+ # List of curl settings
37
+ #
38
+ # @return array
39
+ attr_accessor :curl_settings
40
+
41
+ # Wether or not exceptions should be thrown when a HTTP error is returned.
42
+ #
43
+ # @return bool
44
+ attr_accessor :throw_exceptions
45
+
46
+ # The maximum number of times we'll follow a redirect.
47
+ #
48
+ # @return int
49
+ attr_accessor :max_redirects
50
+
51
+ public
52
+
53
+ # Initializes the client.
54
+ #
55
+ # @return [void]
56
+ def initialize
57
+ initialize_event_emitter_trait
58
+
59
+ @hydra = nil
60
+ @throw_exceptions = false
61
+ @max_redirects = 5
62
+ @hydra_settings = {
63
+ header: false, # RUBY otherwise header will be part of response.body
64
+ nobody: false
65
+ }
66
+ @client_map = {}
67
+ end
68
+
69
+ # Sends a request to a HTTP server, and returns a response.
70
+ #
71
+ # @param RequestInterface request
72
+ # @return [ResponseInterface]
73
+ def send(request)
74
+ emit('beforeRequest', [request])
75
+
76
+ retry_count = 0
77
+ redirects = 0
78
+
79
+ response = nil
80
+ code = 0
81
+
82
+ loop do
83
+ do_redirect = false
84
+ do_retry = false
85
+
86
+ begin
87
+ response = do_request(request)
88
+
89
+ code = response.status.to_i
90
+
91
+ # We are doing in-PHP redirects, because curl's
92
+ # FOLLOW_LOCATION throws errors when PHP is configured with
93
+ # open_basedir.
94
+ #
95
+ # https://github.com/fruux/sabre-http/issues/12
96
+ if [301, 302, 307, 308].include?(code) && redirects < @max_redirects
97
+ old_location = request.url
98
+
99
+ # Creating a new instance of the request object.
100
+ request = request.clone
101
+
102
+ # Setting the new location
103
+ request.set_url(
104
+ Tilia::Uri.resolve(
105
+ old_location,
106
+ response.header('Location')
107
+ )
108
+ )
109
+
110
+ do_redirect = true
111
+ redirects += 1
112
+ end
113
+
114
+ # This was a HTTP error
115
+ if code >= 400
116
+ box = Box.new(do_retry)
117
+ emit('error', [request, response, box, retry_count])
118
+ emit("error:#{code}", [request, response, box, retry_count])
119
+ do_retry = box.value
120
+ end
121
+ rescue Tilia::Http::ClientException => e
122
+ box = Box.new(do_retry)
123
+ emit('exception', [request, e, box, retry_count])
124
+ do_retry = box.value
125
+
126
+ # If retry was still set to false, it means no event handler
127
+ # dealt with the problem. In this case we just re-throw the
128
+ # exception.
129
+ raise e unless do_retry
130
+ end
131
+
132
+ retry_count += 1 if do_retry
133
+
134
+ break unless do_retry || do_redirect
135
+ end
136
+
137
+ emit('afterRequest', [request, response])
138
+
139
+ fail Tilia::Http::ClientHttpException.new(response), 'Oh oh' if @throw_exceptions && code >= 400
140
+
141
+ response
142
+ end
143
+
144
+ # Sends a HTTP request asynchronously.
145
+ #
146
+ # Due to the nature of PHP, you must from time to time poll to see if any
147
+ # new responses came in.
148
+ #
149
+ # After calling sendAsync, you must therefore occasionally call the poll
150
+ # method, or wait.
151
+ #
152
+ # @param RequestInterface request
153
+ # @param callable success
154
+ # @param callable error
155
+ # @return [void]
156
+ def send_async(request, success = nil, error = nil)
157
+ emit('beforeRequest', [request])
158
+
159
+ send_async_internal(request, success, error)
160
+ poll
161
+ end
162
+
163
+ # This method checks if any http requests have gotten results, and if so,
164
+ # call the appropriate success or error handlers.
165
+ #
166
+ # This method will return true if there are still requests waiting to
167
+ # return, and false if all the work is done.
168
+ #
169
+ # @return bool
170
+ def poll
171
+ # nothing to do?
172
+ return false if @client_map.empty?
173
+
174
+ # Hydra finishes them all
175
+ @hydra.run
176
+
177
+ @client_map.keys.each do |handler|
178
+ (
179
+ request,
180
+ success_callback,
181
+ error_callback,
182
+ retry_count,
183
+ ) = @client_map[handler]
184
+ @client_map.delete handler
185
+
186
+ curl_result = parse_curl_result(handler)
187
+ do_retry = false
188
+
189
+ if curl_result['status'] == self.class::STATUS_CURLERROR
190
+ e = Exception.new
191
+
192
+ box = Box.new(do_retry)
193
+ emit('exception', [request, e, box, retry_count])
194
+ do_retry = box.value
195
+
196
+ if do_retry
197
+ retry_count += 1
198
+ send_async_internal(request, success_callback, error_callback, retry_count)
199
+ next
200
+ end
201
+
202
+ curl_result['request'] = request
203
+
204
+ error_callback.call(curl_result) if error_callback
205
+ elsif curl_result['status'] == self.class::STATUS_HTTPERROR
206
+ box = Box.new(do_retry)
207
+ emit('error', [request, curl_result['response'], box, retry_count])
208
+ emit("error:#{curl_result['http_code']}", [request, curl_result['response'], box, retry_count])
209
+ do_retry = box.value
210
+
211
+ if do_retry
212
+ retry_count += 1
213
+ send_async_internal(request, success_callback, error_callback, retry_count)
214
+ next
215
+ end
216
+
217
+ curl_result['request'] = request
218
+
219
+ error_callback.call(curl_result) if error_callback
220
+ else
221
+ emit('afterRequest', [request, curl_result['response']])
222
+
223
+ success_callback.call(curl_result['response']) if success_callback
224
+ end
225
+
226
+ break if @client_map.empty?
227
+ end
228
+
229
+ @client_map.any?
230
+ end
231
+
232
+ # Processes every HTTP request in the queue, and waits till they are all
233
+ # completed.
234
+ #
235
+ # @return [void]
236
+ def wait
237
+ loop do
238
+ still_running = poll
239
+ break unless still_running
240
+ end
241
+ end
242
+
243
+ # If this is set to true, the Client will automatically throw exceptions
244
+ # upon HTTP errors.
245
+ #
246
+ # This means that if a response came back with a status code greater than
247
+ # or equal to 400, we will throw a ClientHttpException.
248
+ #
249
+ # This only works for the send method. Throwing exceptions for
250
+ # send_async is not supported.
251
+ #
252
+ # @param bool throw_exceptions
253
+ # @return [void]
254
+ attr_writer :throw_exceptions
255
+
256
+ # Adds a CURL setting.
257
+ #
258
+ # These settings will be included in every HTTP request.
259
+ #
260
+ # @param int name
261
+ # @param mixed value
262
+ # @return [void]
263
+ def add_curl_setting(name, value)
264
+ @hydra_settings[name] = value
265
+ end
266
+
267
+ protected
268
+
269
+ # This method is responsible for performing a single request.
270
+ #
271
+ # @param RequestInterface request
272
+ # @return [ResponseInterface]
273
+ def do_request(request)
274
+ client = create_client(request)
275
+ client.run
276
+
277
+ response = parse_curl_result(client)
278
+
279
+ if response['status'] == self.class::STATUS_CURLERROR
280
+ fail Tilia::Http::ClientException.new(response['curl_errno']), response['curl_errmsg']
281
+ end
282
+
283
+ response['response']
284
+ end
285
+
286
+ protected
287
+
288
+ # Cached curl handle.
289
+ #
290
+ # By keeping this resource around for the lifetime of this object, things
291
+ # like persistent connections are possible.
292
+ #
293
+ # @return resource
294
+ attr_accessor :curl_handle
295
+
296
+ # Handler for curl_multi requests.
297
+ #
298
+ # The first time sendAsync is used, this will be created.
299
+ #
300
+ # @return resource
301
+ attr_accessor :curl_multi_handle
302
+
303
+ # Has a list of curl handles, as well as their associated success and
304
+ # error callbacks.
305
+ #
306
+ # @return array
307
+ attr_accessor :curl_multi_map
308
+
309
+ public
310
+
311
+ STATUS_SUCCESS = 0
312
+ STATUS_CURLERROR = 1
313
+ STATUS_HTTPERROR = 2
314
+
315
+ # Parses the result of a curl call in a format that's a bit more
316
+ # convenient to work with.
317
+ #
318
+ # The method returns an array with the following elements:
319
+ # * status - one of the 3 STATUS constants.
320
+ # * curl_errno - A curl error number. Only set if status is
321
+ # STATUS_CURLERROR.
322
+ # * curl_errmsg - A current error message. Only set if status is
323
+ # STATUS_CURLERROR.
324
+ # * response - Response object. Only set if status is STATUS_SUCCESS, or
325
+ # STATUS_HTTPERROR.
326
+ # * http_code - HTTP status code, as an int. Only set if Only set if
327
+ # status is STATUS_SUCCESS, or STATUS_HTTPERROR
328
+ #
329
+ # @param [String] response
330
+ # @param resource curl_handle
331
+ # @return Response
332
+ def parse_curl_result(client)
333
+ client_response = client.response
334
+ unless client_response.return_code == :ok
335
+ return {
336
+ 'status' => self.class::STATUS_CURLERROR,
337
+ 'curl_errno' => client_response.return_code,
338
+ 'curl_errmsg' => client_response.return_message
339
+ }
340
+ end
341
+
342
+ header_blob = client_response.response_headers
343
+ # In the case of 204 No Content, strlen(response) == curl_info['header_size].
344
+ # This will cause substr(response, curl_info['header_size']) return FALSE instead of NULL
345
+ # An exception will be thrown when calling getBodyAsString then
346
+ response_body = client_response.body
347
+ response_body = nil if response_body == ''
348
+
349
+ # In the case of 100 Continue, or redirects we'll have multiple lists
350
+ # of headers for each separate HTTP response. We can easily split this
351
+ # because they are separated by \r\n\r\n
352
+ header_blob = header_blob.strip.split(/\r?\n\r?\n/)
353
+
354
+ # We only care about the last set of headers
355
+ header_blob = header_blob[-1]
356
+
357
+ # Splitting headers
358
+ header_blob = header_blob.split(/\r?\n/)
359
+
360
+ response = Tilia::Http::Response.new
361
+ response.status = client_response.code
362
+
363
+ header_blob.each do |header|
364
+ parts = header.split(':', 2)
365
+
366
+ response.add_header(parts[0].strip, parts[1].strip) if parts.size == 2
367
+ end
368
+
369
+ response.body = response_body
370
+
371
+ http_code = response.status.to_i
372
+
373
+ {
374
+ 'status' => http_code >= 400 ? self.class::STATUS_HTTPERROR : self.class::STATUS_SUCCESS,
375
+ 'response' => response,
376
+ 'http_code' => http_code
377
+ }
378
+ end
379
+
380
+ # Sends an asynchronous HTTP request.
381
+ #
382
+ # We keep this in a separate method, so we can call it without triggering
383
+ # the beforeRequest event and don't do the poll.
384
+ #
385
+ # @param RequestInterface request
386
+ # @param callable success
387
+ # @param callable error
388
+ # @param int retry_count
389
+ def send_async_internal(request, success, error, retry_count = 0)
390
+ @hydra = Typhoeus::Hydra.hydra unless @hydra
391
+
392
+ client = create_client(request)
393
+ @hydra.queue client
394
+
395
+ @client_map[client] = [
396
+ request,
397
+ success,
398
+ error,
399
+ retry_count
400
+ ]
401
+ end
402
+
403
+ # TODO: document
404
+ def create_client(request)
405
+ settings = {}
406
+ @hydra_settings.each do |key, value|
407
+ settings[key] = value
408
+ end
409
+
410
+ case request.method
411
+ when 'HEAD'
412
+ settings[:nobody] = true
413
+ settings[:method] = :head
414
+ settings[:postfields] = ''
415
+ settings[:put] = false
416
+ when 'GET'
417
+ settings[:method] = :get
418
+ settings[:postfields] = ''
419
+ settings[:put] = false
420
+ else
421
+ settings[:method] = request.method.downcase.to_sym
422
+ body = request.body
423
+ if !body.is_a?(String) && !body.nil?
424
+ settings[:put] = true
425
+ settings[:infile] = body
426
+ else
427
+ settings[:postfields] = body.to_s
428
+ end
429
+ end
430
+
431
+ settings[:headers] = {}
432
+ request.headers.each do |key, values|
433
+ settings[:headers][key] = values.join("\n")
434
+ end
435
+ settings[:protocols] = [:http, :https]
436
+ settings[:redir_protocols] = [:http, :https]
437
+
438
+ client = Typhoeus::Request.new(request.url, settings)
439
+ client
440
+ end
441
+ end
442
+
443
+ # Class to fix rubys real-world "pass-by-reference" shortcommings
444
+ class Box
445
+ attr_accessor :value
446
+
447
+ def initialize(v = nil)
448
+ @value = v
449
+ end
450
+ end
451
+ end
452
+ end
@@ -0,0 +1,15 @@
1
+ module Tilia
2
+ module Http
3
+ # This exception may be emitted by the HTTP\Client class, in case there was a
4
+ # problem emitting the request.
5
+ class ClientException < Exception
6
+ # TODO: document
7
+ def initialize(code)
8
+ @code = code.to_i
9
+ end
10
+
11
+ # TODO: document
12
+ attr_accessor :code
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,37 @@
1
+ module Tilia
2
+ module Http
3
+ # This exception represents a HTTP error coming from the Client.
4
+ #
5
+ # By default the Client will not emit these, this has to be explicitly enabled
6
+ # with the setThrowExceptions method.
7
+ class ClientHttpException < Tilia::Http::HttpException
8
+ protected
9
+
10
+ # Response object
11
+ #
12
+ # @return [ResponseInterface]
13
+ attr_accessor :response
14
+
15
+ public
16
+
17
+ # Constructor
18
+ #
19
+ # @param ResponseInterface response
20
+ def initialize(response)
21
+ @response = response
22
+ end
23
+
24
+ # The http status code for the error.
25
+ #
26
+ # @return int
27
+ def http_status
28
+ @response.status
29
+ end
30
+
31
+ # Returns the full response object.
32
+ #
33
+ # @return [ResponseInterface]
34
+ attr_reader :response
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,21 @@
1
+ module Tilia
2
+ module Http
3
+ # An exception representing a HTTP error.
4
+ #
5
+ # This can be used as a generic exception in your application, if you'd like
6
+ # to map HTTP errors to exceptions.
7
+ #
8
+ # If you'd like to use this, create a new exception class, extending Exception
9
+ # and implementing this interface.
10
+ class HttpException < Exception
11
+ # The http status code for the error.
12
+ #
13
+ # This may either be just the number, or a number and a human-readable
14
+ # message, separated by a space.
15
+ #
16
+ # @return [String, nil]
17
+ def http_status
18
+ end
19
+ end
20
+ end
21
+ end