tilia-http 4.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +19 -0
- data/.rspec +1 -0
- data/.rubocop.yml +35 -0
- data/.simplecov +4 -0
- data/.travis.yml +3 -0
- data/CHANGELOG.sabre.md +235 -0
- data/CONTRIBUTING.md +25 -0
- data/Gemfile +18 -0
- data/Gemfile.lock +69 -0
- data/LICENSE +27 -0
- data/LICENSE.sabre +27 -0
- data/README.md +68 -0
- data/Rakefile +17 -0
- data/examples/asyncclient.rb +45 -0
- data/examples/basicauth.rb +39 -0
- data/examples/client.rb +20 -0
- data/examples/reverseproxy.rb +39 -0
- data/examples/stringify.rb +37 -0
- data/lib/tilia/http/auth/abstract_auth.rb +51 -0
- data/lib/tilia/http/auth/aws.rb +191 -0
- data/lib/tilia/http/auth/basic.rb +43 -0
- data/lib/tilia/http/auth/bearer.rb +37 -0
- data/lib/tilia/http/auth/digest.rb +187 -0
- data/lib/tilia/http/auth.rb +12 -0
- data/lib/tilia/http/client.rb +452 -0
- data/lib/tilia/http/client_exception.rb +15 -0
- data/lib/tilia/http/client_http_exception.rb +37 -0
- data/lib/tilia/http/http_exception.rb +21 -0
- data/lib/tilia/http/message.rb +241 -0
- data/lib/tilia/http/message_decorator_trait.rb +183 -0
- data/lib/tilia/http/message_interface.rb +154 -0
- data/lib/tilia/http/request.rb +235 -0
- data/lib/tilia/http/request_decorator.rb +160 -0
- data/lib/tilia/http/request_interface.rb +126 -0
- data/lib/tilia/http/response.rb +164 -0
- data/lib/tilia/http/response_decorator.rb +58 -0
- data/lib/tilia/http/response_interface.rb +36 -0
- data/lib/tilia/http/sapi.rb +165 -0
- data/lib/tilia/http/url_util.rb +70 -0
- data/lib/tilia/http/util.rb +51 -0
- data/lib/tilia/http/version.rb +9 -0
- data/lib/tilia/http.rb +416 -0
- data/test/http/auth/aws_test.rb +189 -0
- data/test/http/auth/basic_test.rb +60 -0
- data/test/http/auth/bearer_test.rb +47 -0
- data/test/http/auth/digest_test.rb +141 -0
- data/test/http/client_mock.rb +101 -0
- data/test/http/client_test.rb +331 -0
- data/test/http/message_decorator_test.rb +67 -0
- data/test/http/message_test.rb +163 -0
- data/test/http/request_decorator_test.rb +87 -0
- data/test/http/request_test.rb +132 -0
- data/test/http/response_decorator_test.rb +28 -0
- data/test/http/response_test.rb +38 -0
- data/test/http/sapi_mock.rb +12 -0
- data/test/http/sapi_test.rb +133 -0
- data/test/http/url_util_test.rb +155 -0
- data/test/http/util_test.rb +186 -0
- data/test/http_test.rb +102 -0
- data/test/test_helper.rb +6 -0
- data/tilia-http.gemspec +18 -0
- 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
|