tilia-http 4.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 (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