faraday 0.7.4 → 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (119) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +276 -0
  3. data/LICENSE.md +1 -1
  4. data/README.md +40 -153
  5. data/Rakefile +4 -139
  6. data/examples/client_spec.rb +65 -0
  7. data/examples/client_test.rb +79 -0
  8. data/lib/faraday/adapter/em_http.rb +286 -0
  9. data/lib/faraday/adapter/em_http_ssl_patch.rb +62 -0
  10. data/lib/faraday/adapter/em_synchrony/parallel_manager.rb +69 -0
  11. data/lib/faraday/adapter/em_synchrony.rb +120 -36
  12. data/lib/faraday/adapter/excon.rb +108 -12
  13. data/lib/faraday/adapter/httpclient.rb +152 -0
  14. data/lib/faraday/adapter/net_http.rb +187 -43
  15. data/lib/faraday/adapter/net_http_persistent.rb +91 -0
  16. data/lib/faraday/adapter/patron.rb +106 -10
  17. data/lib/faraday/adapter/rack.rb +75 -0
  18. data/lib/faraday/adapter/test.rb +160 -61
  19. data/lib/faraday/adapter/typhoeus.rb +7 -46
  20. data/lib/faraday/adapter.rb +105 -33
  21. data/lib/faraday/adapter_registry.rb +30 -0
  22. data/lib/faraday/autoload.rb +95 -0
  23. data/lib/faraday/connection.rb +525 -157
  24. data/lib/faraday/dependency_loader.rb +37 -0
  25. data/lib/faraday/encoders/flat_params_encoder.rb +98 -0
  26. data/lib/faraday/encoders/nested_params_encoder.rb +171 -0
  27. data/lib/faraday/error.rb +122 -30
  28. data/lib/faraday/file_part.rb +128 -0
  29. data/lib/faraday/logging/formatter.rb +105 -0
  30. data/lib/faraday/middleware.rb +14 -22
  31. data/lib/faraday/middleware_registry.rb +129 -0
  32. data/lib/faraday/options/connection_options.rb +22 -0
  33. data/lib/faraday/options/env.rb +181 -0
  34. data/lib/faraday/options/proxy_options.rb +28 -0
  35. data/lib/faraday/options/request_options.rb +22 -0
  36. data/lib/faraday/options/ssl_options.rb +59 -0
  37. data/lib/faraday/options.rb +222 -0
  38. data/lib/faraday/param_part.rb +53 -0
  39. data/lib/faraday/parameters.rb +5 -0
  40. data/lib/faraday/rack_builder.rb +248 -0
  41. data/lib/faraday/request/authorization.rb +55 -0
  42. data/lib/faraday/request/basic_authentication.rb +20 -0
  43. data/lib/faraday/request/instrumentation.rb +54 -0
  44. data/lib/faraday/request/multipart.rb +84 -48
  45. data/lib/faraday/request/retry.rb +239 -0
  46. data/lib/faraday/request/token_authentication.rb +20 -0
  47. data/lib/faraday/request/url_encoded.rb +46 -27
  48. data/lib/faraday/request.rb +112 -50
  49. data/lib/faraday/response/logger.rb +24 -25
  50. data/lib/faraday/response/raise_error.rb +40 -11
  51. data/lib/faraday/response.rb +44 -35
  52. data/lib/faraday/utils/headers.rb +139 -0
  53. data/lib/faraday/utils/params_hash.rb +61 -0
  54. data/lib/faraday/utils.rb +72 -117
  55. data/lib/faraday.rb +142 -64
  56. data/spec/external_adapters/faraday_specs_setup.rb +14 -0
  57. data/spec/faraday/adapter/em_http_spec.rb +47 -0
  58. data/spec/faraday/adapter/em_synchrony_spec.rb +16 -0
  59. data/spec/faraday/adapter/excon_spec.rb +49 -0
  60. data/spec/faraday/adapter/httpclient_spec.rb +73 -0
  61. data/spec/faraday/adapter/net_http_persistent_spec.rb +57 -0
  62. data/spec/faraday/adapter/net_http_spec.rb +64 -0
  63. data/spec/faraday/adapter/patron_spec.rb +18 -0
  64. data/spec/faraday/adapter/rack_spec.rb +8 -0
  65. data/spec/faraday/adapter/typhoeus_spec.rb +7 -0
  66. data/spec/faraday/adapter_registry_spec.rb +28 -0
  67. data/spec/faraday/adapter_spec.rb +55 -0
  68. data/spec/faraday/composite_read_io_spec.rb +80 -0
  69. data/spec/faraday/connection_spec.rb +691 -0
  70. data/spec/faraday/error_spec.rb +45 -0
  71. data/spec/faraday/middleware_spec.rb +26 -0
  72. data/spec/faraday/options/env_spec.rb +70 -0
  73. data/spec/faraday/options/options_spec.rb +297 -0
  74. data/spec/faraday/options/proxy_options_spec.rb +37 -0
  75. data/spec/faraday/options/request_options_spec.rb +19 -0
  76. data/spec/faraday/params_encoders/flat_spec.rb +34 -0
  77. data/spec/faraday/params_encoders/nested_spec.rb +134 -0
  78. data/spec/faraday/rack_builder_spec.rb +196 -0
  79. data/spec/faraday/request/authorization_spec.rb +88 -0
  80. data/spec/faraday/request/instrumentation_spec.rb +76 -0
  81. data/spec/faraday/request/multipart_spec.rb +274 -0
  82. data/spec/faraday/request/retry_spec.rb +242 -0
  83. data/spec/faraday/request/url_encoded_spec.rb +83 -0
  84. data/spec/faraday/request_spec.rb +109 -0
  85. data/spec/faraday/response/logger_spec.rb +220 -0
  86. data/spec/faraday/response/middleware_spec.rb +68 -0
  87. data/spec/faraday/response/raise_error_spec.rb +106 -0
  88. data/spec/faraday/response_spec.rb +75 -0
  89. data/spec/faraday/utils/headers_spec.rb +82 -0
  90. data/spec/faraday/utils_spec.rb +56 -0
  91. data/spec/faraday_spec.rb +37 -0
  92. data/spec/spec_helper.rb +132 -0
  93. data/spec/support/disabling_stub.rb +14 -0
  94. data/spec/support/fake_safe_buffer.rb +15 -0
  95. data/spec/support/helper_methods.rb +133 -0
  96. data/spec/support/shared_examples/adapter.rb +104 -0
  97. data/spec/support/shared_examples/params_encoder.rb +18 -0
  98. data/spec/support/shared_examples/request_method.rb +234 -0
  99. data/spec/support/streaming_response_checker.rb +35 -0
  100. data/spec/support/webmock_rack_app.rb +68 -0
  101. metadata +126 -126
  102. data/Gemfile +0 -29
  103. data/config.ru +0 -6
  104. data/faraday.gemspec +0 -92
  105. data/lib/faraday/adapter/action_dispatch.rb +0 -29
  106. data/lib/faraday/builder.rb +0 -160
  107. data/lib/faraday/request/json.rb +0 -35
  108. data/lib/faraday/upload_io.rb +0 -23
  109. data/test/adapters/live_test.rb +0 -205
  110. data/test/adapters/logger_test.rb +0 -37
  111. data/test/adapters/net_http_test.rb +0 -33
  112. data/test/adapters/test_middleware_test.rb +0 -70
  113. data/test/connection_test.rb +0 -254
  114. data/test/env_test.rb +0 -158
  115. data/test/helper.rb +0 -41
  116. data/test/live_server.rb +0 -45
  117. data/test/middleware_stack_test.rb +0 -118
  118. data/test/request_middleware_test.rb +0 -116
  119. data/test/response_middleware_test.rb +0 -74
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Requires Ruby with rspec and faraday gems.
4
+ # rspec client_spec.rb
5
+
6
+ require 'faraday'
7
+ require 'json'
8
+
9
+ # Example API client
10
+ class Client
11
+ def initialize(conn)
12
+ @conn = conn
13
+ end
14
+
15
+ def sushi(jname)
16
+ res = @conn.get("/#{jname}")
17
+ data = JSON.parse(res.body)
18
+ data['name']
19
+ end
20
+ end
21
+
22
+ Rspec.describe Client do
23
+ let(:stubs) { Faraday::Adapter::Test::Stubs.new }
24
+ let(:conn) { Faraday.new { |b| b.adapter(:test, stubs) } }
25
+ let(:client) { Client.new(conn) }
26
+
27
+ it 'parses name' do
28
+ stubs.get('/ebi') do |env|
29
+ # optional: you can inspect the Faraday::Env
30
+ expect(env.url.path).to eq('/ebi')
31
+ [
32
+ 200,
33
+ { 'Content-Type': 'application/javascript' },
34
+ '{"name": "shrimp"}'
35
+ ]
36
+ end
37
+
38
+ # uncomment to trigger stubs.verify_stubbed_calls failure
39
+ # stubs.get('/unused') { [404, {}, ''] }
40
+
41
+ expect(client.sushi('ebi')).to eq('shrimp')
42
+ stubs.verify_stubbed_calls
43
+ end
44
+
45
+ it 'handles 404' do
46
+ stubs.get('/ebi') do
47
+ [
48
+ 404,
49
+ { 'Content-Type': 'application/javascript' },
50
+ '{}'
51
+ ]
52
+ end
53
+ expect(client.sushi('ebi')).to be_nil
54
+ stubs.verify_stubbed_calls
55
+ end
56
+
57
+ it 'handles exception' do
58
+ stubs.get('/ebi') do
59
+ raise Faraday::ConnectionFailed, nil
60
+ end
61
+
62
+ expect { client.sushi('ebi') }.to raise_error(Faraday::ConnectionFailed)
63
+ stubs.verify_stubbed_calls
64
+ end
65
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Requires Ruby with test-unit and faraday gems.
4
+ # ruby client_test.rb
5
+
6
+ require 'faraday'
7
+ require 'json'
8
+ require 'test/unit'
9
+
10
+ # Example API client
11
+ class Client
12
+ def initialize(conn)
13
+ @conn = conn
14
+ end
15
+
16
+ def sushi(jname)
17
+ res = @conn.get("/#{jname}")
18
+ data = JSON.parse(res.body)
19
+ data['name']
20
+ end
21
+ end
22
+
23
+ # Example API client test
24
+ class ClientTest < Test::Unit::TestCase
25
+ def test_sushi_name
26
+ stubs = Faraday::Adapter::Test::Stubs.new
27
+ stubs.get('/ebi') do |env|
28
+ # optional: you can inspect the Faraday::Env
29
+ assert_equal '/ebi', env.url.path
30
+ [
31
+ 200,
32
+ { 'Content-Type': 'application/javascript' },
33
+ '{"name": "shrimp"}'
34
+ ]
35
+ end
36
+
37
+ # uncomment to trigger stubs.verify_stubbed_calls failure
38
+ # stubs.get('/unused') { [404, {}, ''] }
39
+
40
+ cli = client(stubs)
41
+ assert_equal 'shrimp', cli.sushi('ebi')
42
+ stubs.verify_stubbed_calls
43
+ end
44
+
45
+ def test_sushi_404
46
+ stubs = Faraday::Adapter::Test::Stubs.new
47
+ stubs.get('/ebi') do
48
+ [
49
+ 404,
50
+ { 'Content-Type': 'application/javascript' },
51
+ '{}'
52
+ ]
53
+ end
54
+
55
+ cli = client(stubs)
56
+ assert_nil cli.sushi('ebi')
57
+ stubs.verify_stubbed_calls
58
+ end
59
+
60
+ def test_sushi_exception
61
+ stubs = Faraday::Adapter::Test::Stubs.new
62
+ stubs.get('/ebi') do
63
+ raise Faraday::ConnectionFailed, nil
64
+ end
65
+
66
+ cli = client(stubs)
67
+ assert_raise Faraday::ConnectionFailed do
68
+ cli.sushi('ebi')
69
+ end
70
+ stubs.verify_stubbed_calls
71
+ end
72
+
73
+ def client(stubs)
74
+ conn = Faraday.new do |builder|
75
+ builder.adapter :test, stubs
76
+ end
77
+ Client.new(conn)
78
+ end
79
+ end
@@ -0,0 +1,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Faraday
4
+ class Adapter
5
+ # EventMachine adapter. This adapter is useful for either asynchronous
6
+ # requests when in an EM reactor loop, or for making parallel requests in
7
+ # synchronous code.
8
+ class EMHttp < Faraday::Adapter
9
+ # Options is a module containing helpers to convert the Faraday env object
10
+ # into options hashes for EMHTTP method calls.
11
+ module Options
12
+ # @return [Hash]
13
+ def connection_config(env)
14
+ options = {}
15
+ configure_proxy(options, env)
16
+ configure_timeout(options, env)
17
+ configure_socket(options, env)
18
+ configure_ssl(options, env)
19
+ options
20
+ end
21
+
22
+ def request_config(env)
23
+ options = {
24
+ body: read_body(env),
25
+ head: env[:request_headers]
26
+ # keepalive: true,
27
+ # file: 'path/to/file', # stream data off disk
28
+ }
29
+ configure_compression(options, env)
30
+ options
31
+ end
32
+
33
+ def read_body(env)
34
+ body = env[:body]
35
+ body.respond_to?(:read) ? body.read : body
36
+ end
37
+
38
+ # Reads out proxy settings from env into options
39
+ def configure_proxy(options, env)
40
+ proxy = request_options(env)[:proxy]
41
+ return unless proxy
42
+
43
+ options[:proxy] = {
44
+ host: proxy[:uri].host,
45
+ port: proxy[:uri].port,
46
+ authorization: [proxy[:user], proxy[:password]]
47
+ }
48
+ end
49
+
50
+ # Reads out host and port settings from env into options
51
+ def configure_socket(options, env)
52
+ bind = request_options(env)[:bind]
53
+ return unless bind
54
+
55
+ options[:bind] = {
56
+ host: bind[:host],
57
+ port: bind[:port]
58
+ }
59
+ end
60
+
61
+ # Reads out SSL certificate settings from env into options
62
+ def configure_ssl(options, env)
63
+ return unless env[:url].scheme == 'https' && env[:ssl]
64
+
65
+ options[:ssl] = {
66
+ cert_chain_file: env[:ssl][:ca_file],
67
+ verify_peer: env[:ssl].fetch(:verify, true)
68
+ }
69
+ end
70
+
71
+ # Reads out timeout settings from env into options
72
+ def configure_timeout(options, env)
73
+ req = request_options(env)
74
+ options[:inactivity_timeout] = request_timeout(:read, req)
75
+ options[:connect_timeout] = request_timeout(:open, req)
76
+ end
77
+
78
+ # Reads out compression header settings from env into options
79
+ def configure_compression(options, env)
80
+ return unless (env[:method] == :get) &&
81
+ !options[:head].key?('accept-encoding')
82
+
83
+ options[:head]['accept-encoding'] = 'gzip, compressed'
84
+ end
85
+
86
+ def request_options(env)
87
+ env[:request]
88
+ end
89
+ end
90
+
91
+ include Options
92
+
93
+ dependency 'em-http'
94
+
95
+ self.supports_parallel = true
96
+
97
+ # @return [Manager]
98
+ def self.setup_parallel_manager(_options = nil)
99
+ Manager.new
100
+ end
101
+
102
+ def call(env)
103
+ super
104
+ perform_request env
105
+ @app.call env
106
+ end
107
+
108
+ def perform_request(env)
109
+ if parallel?(env)
110
+ manager = env[:parallel_manager]
111
+ manager.add do
112
+ perform_single_request(env)
113
+ .callback { env[:response].finish(env) }
114
+ end
115
+ elsif EventMachine.reactor_running?
116
+ # EM is running: instruct upstream that this is an async request
117
+ env[:parallel_manager] = true
118
+ perform_single_request(env)
119
+ .callback { env[:response].finish(env) }
120
+ .errback do
121
+ # TODO: no way to communicate the error in async mode
122
+ raise NotImplementedError
123
+ end
124
+ else
125
+ error = nil
126
+ # start EM, block until request is completed
127
+ EventMachine.run do
128
+ perform_single_request(env)
129
+ .callback { EventMachine.stop }
130
+ .errback do |client|
131
+ error = error_message(client)
132
+ EventMachine.stop
133
+ end
134
+ end
135
+ raise_error(error) if error
136
+ end
137
+ rescue EventMachine::Connectify::CONNECTError => e
138
+ if e.message.include?('Proxy Authentication Required')
139
+ raise Faraday::ConnectionFailed,
140
+ %(407 "Proxy Authentication Required ")
141
+ end
142
+
143
+ raise Faraday::ConnectionFailed, e
144
+ rescue StandardError => e
145
+ if defined?(::OpenSSL::SSL::SSLError) && \
146
+ e.is_a?(::OpenSSL::SSL::SSLError)
147
+ raise Faraday::SSLError, e
148
+ end
149
+
150
+ raise
151
+ end
152
+
153
+ # TODO: reuse the connection to support pipelining
154
+ def perform_single_request(env)
155
+ req = create_request(env)
156
+ req = req.setup_request(env[:method], request_config(env))
157
+ req.callback do |client|
158
+ if env[:request].stream_response?
159
+ warn "Streaming downloads for #{self.class.name} " \
160
+ 'are not yet implemented.'
161
+ env[:request].on_data.call(
162
+ client.response,
163
+ client.response.bytesize
164
+ )
165
+ end
166
+ status = client.response_header.status
167
+ reason = client.response_header.http_reason
168
+ save_response(env, status, client.response, nil, reason) do |headers|
169
+ client.response_header.each do |name, value|
170
+ headers[name.to_sym] = value
171
+ end
172
+ end
173
+ end
174
+ end
175
+
176
+ def create_request(env)
177
+ EventMachine::HttpRequest.new(
178
+ env[:url], connection_config(env).merge(@connection_options)
179
+ )
180
+ end
181
+
182
+ def error_message(client)
183
+ client.error || 'request failed'
184
+ end
185
+
186
+ def raise_error(msg)
187
+ error_class = Faraday::ClientError
188
+ if timeout_message?(msg)
189
+ error_class = Faraday::TimeoutError
190
+ msg = 'request timed out'
191
+ elsif msg == Errno::ECONNREFUSED
192
+ error_class = Faraday::ConnectionFailed
193
+ msg = 'connection refused'
194
+ elsif msg == 'connection closed by server'
195
+ error_class = Faraday::ConnectionFailed
196
+ end
197
+ raise error_class, msg
198
+ end
199
+
200
+ def timeout_message?(msg)
201
+ msg == Errno::ETIMEDOUT ||
202
+ (msg.is_a?(String) && msg.include?('timeout error'))
203
+ end
204
+
205
+ # @return [Boolean]
206
+ def parallel?(env)
207
+ !!env[:parallel_manager]
208
+ end
209
+
210
+ # This parallel manager is designed to start an EventMachine loop
211
+ # and block until all registered requests have been completed.
212
+ class Manager
213
+ # @see reset
214
+ def initialize
215
+ reset
216
+ end
217
+
218
+ # Re-initializes instance variables
219
+ def reset
220
+ @registered_procs = []
221
+ @num_registered = 0
222
+ @num_succeeded = 0
223
+ @errors = []
224
+ @running = false
225
+ end
226
+
227
+ # @return [Boolean]
228
+ def running?
229
+ @running
230
+ end
231
+
232
+ def add(&block)
233
+ if running?
234
+ perform_request { yield }
235
+ else
236
+ @registered_procs << block
237
+ end
238
+ @num_registered += 1
239
+ end
240
+
241
+ def run
242
+ if @num_registered.positive?
243
+ @running = true
244
+ EventMachine.run do
245
+ @registered_procs.each do |proc|
246
+ perform_request(&proc)
247
+ end
248
+ end
249
+ unless @errors.empty?
250
+ raise Faraday::ClientError, @errors.first || 'connection failed'
251
+ end
252
+ end
253
+ ensure
254
+ reset
255
+ end
256
+
257
+ def perform_request
258
+ client = yield
259
+ client.callback do
260
+ @num_succeeded += 1
261
+ check_finished
262
+ end
263
+ client.errback do
264
+ @errors << client.error
265
+ check_finished
266
+ end
267
+ end
268
+
269
+ def check_finished
270
+ EventMachine.stop if @num_succeeded + @errors.size == @num_registered
271
+ end
272
+ end
273
+ end
274
+ end
275
+ end
276
+
277
+ if Faraday::Adapter::EMHttp.loaded?
278
+ begin
279
+ require 'openssl'
280
+ rescue LoadError
281
+ warn 'Warning: no such file to load -- openssl. ' \
282
+ 'Make sure it is installed if you want HTTPS support'
283
+ else
284
+ require 'faraday/adapter/em_http_ssl_patch'
285
+ end
286
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'em-http'
5
+
6
+ # EventMachine patch to make SSL work.
7
+ module EmHttpSslPatch
8
+ def ssl_verify_peer(cert_string)
9
+ begin
10
+ @last_seen_cert = OpenSSL::X509::Certificate.new(cert_string)
11
+ rescue OpenSSL::X509::CertificateError
12
+ return false
13
+ end
14
+
15
+ unless certificate_store.verify(@last_seen_cert)
16
+ raise OpenSSL::SSL::SSLError,
17
+ %(unable to verify the server certificate for "#{host}")
18
+ end
19
+
20
+ begin
21
+ certificate_store.add_cert(@last_seen_cert)
22
+ rescue OpenSSL::X509::StoreError => e
23
+ raise e unless e.message == 'cert already in hash table'
24
+ end
25
+ true
26
+ end
27
+
28
+ def ssl_handshake_completed
29
+ return true unless verify_peer?
30
+
31
+ unless verified_cert_identity?
32
+ raise OpenSSL::SSL::SSLError,
33
+ %(host "#{host}" does not match the server certificate)
34
+ end
35
+
36
+ true
37
+ end
38
+
39
+ def verify_peer?
40
+ parent.connopts.tls[:verify_peer]
41
+ end
42
+
43
+ def verified_cert_identity?
44
+ OpenSSL::SSL.verify_certificate_identity(@last_seen_cert, host)
45
+ end
46
+
47
+ def host
48
+ parent.uri.host
49
+ end
50
+
51
+ def certificate_store
52
+ @certificate_store ||= begin
53
+ store = OpenSSL::X509::Store.new
54
+ store.set_default_paths
55
+ ca_file = parent.connopts.tls[:cert_chain_file]
56
+ store.add_file(ca_file) if ca_file
57
+ store
58
+ end
59
+ end
60
+ end
61
+
62
+ EventMachine::HttpStubConnection.include(EmHttpSslPatch)
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Faraday
4
+ class Adapter
5
+ class EMSynchrony < Faraday::Adapter
6
+ # A parallel manager for EMSynchrony.
7
+ class ParallelManager
8
+ # Add requests to queue.
9
+ #
10
+ # @param request [EM::HttpRequest]
11
+ # @param method [Symbol, String] HTTP method
12
+ # @param args [Array] the rest of the positional arguments
13
+ def add(request, method, *args, &block)
14
+ queue << {
15
+ request: request,
16
+ method: method,
17
+ args: args,
18
+ block: block
19
+ }
20
+ end
21
+
22
+ # Run all requests on queue with `EM::Synchrony::Multi`, wrapping
23
+ # it in a reactor and fiber if needed.
24
+ def run
25
+ result = nil
26
+ if !EM.reactor_running?
27
+ EM.run do
28
+ Fiber.new do
29
+ result = perform
30
+ EM.stop
31
+ end.resume
32
+ end
33
+ else
34
+ result = perform
35
+ end
36
+ result
37
+ end
38
+
39
+ private
40
+
41
+ # The request queue.
42
+ def queue
43
+ @queue ||= []
44
+ end
45
+
46
+ # Main `EM::Synchrony::Multi` performer.
47
+ def perform
48
+ multi = ::EM::Synchrony::Multi.new
49
+
50
+ queue.each do |item|
51
+ method = "a#{item[:method]}".to_sym
52
+
53
+ req = item[:request].send(method, *item[:args])
54
+ req.callback(&item[:block])
55
+
56
+ req_name = "req_#{multi.requests.size}".to_sym
57
+ multi.add(req_name, req)
58
+ end
59
+
60
+ # Clear the queue, so parallel manager objects can be reused.
61
+ @queue = []
62
+
63
+ # Block fiber until all requests have returned.
64
+ multi.perform
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end