excon 0.62.0 → 0.63.0
Sign up to get free protection for your applications and to get access to all the features.
Potentially problematic release.
This version of excon might be problematic. Click here for more details.
- checksums.yaml +5 -5
- data/.github/stale.yml +17 -0
- data/.travis.yml +7 -19
- data/LICENSE.md +1 -1
- data/README.md +5 -4
- data/changelog.txt +25 -0
- data/data/cacert.pem +440 -994
- data/excon.gemspec +9 -0
- data/lib/excon.rb +9 -1
- data/lib/excon/connection.rb +52 -35
- data/lib/excon/constants.rb +33 -13
- data/lib/excon/error.rb +3 -0
- data/lib/excon/instrumentors/logging_instrumentor.rb +3 -14
- data/lib/excon/instrumentors/standard_instrumentor.rb +1 -8
- data/lib/excon/middlewares/base.rb +6 -0
- data/lib/excon/middlewares/expects.rb +6 -0
- data/lib/excon/middlewares/idempotent.rb +20 -3
- data/lib/excon/middlewares/instrumentor.rb +8 -0
- data/lib/excon/middlewares/mock.rb +8 -0
- data/lib/excon/middlewares/response_parser.rb +3 -0
- data/lib/excon/pretty_printer.rb +1 -8
- data/lib/excon/socket.rb +36 -10
- data/lib/excon/ssl_socket.rb +7 -0
- data/lib/excon/utils.rb +23 -4
- data/lib/excon/version.rb +1 -1
- data/spec/excon/test/server_spec.rb +2 -2
- data/spec/helpers/warning_helpers.rb +9 -0
- data/spec/requests/unix_socket_spec.rb +2 -10
- data/spec/requests/validation_spec.rb +80 -0
- data/spec/spec_helper.rb +2 -0
- data/spec/support/shared_contexts/test_stub_context.rb +11 -0
- data/spec/support/shared_examples/shared_example_for_clients.rb +6 -4
- data/tests/authorization_header_tests.rb +19 -21
- data/tests/bad_tests.rb +22 -0
- data/tests/batch_requests.rb +1 -1
- data/tests/complete_responses.rb +1 -1
- data/tests/data/127.0.0.1.cert.crt +15 -18
- data/tests/data/127.0.0.1.cert.key +28 -27
- data/tests/data/excon.cert.crt +15 -18
- data/tests/data/excon.cert.key +28 -27
- data/tests/error_tests.rb +1 -1
- data/tests/instrumentors/logging_instrumentor_tests.rb +28 -0
- data/tests/middlewares/decompress_tests.rb +1 -1
- data/tests/middlewares/idempotent_tests.rb +56 -17
- data/tests/middlewares/mock_tests.rb +2 -2
- data/tests/pipeline_tests.rb +1 -1
- data/tests/request_tests.rb +5 -6
- data/tests/response_tests.rb +1 -1
- data/tests/servers/bad.rb +5 -0
- data/tests/servers/good.rb +0 -8
- data/tests/servers/good_ipv4.rb +8 -0
- data/tests/servers/good_ipv6.rb +8 -0
- data/tests/test_helper.rb +27 -36
- metadata +17 -5
@@ -2,6 +2,14 @@
|
|
2
2
|
module Excon
|
3
3
|
module Middleware
|
4
4
|
class Instrumentor < Excon::Middleware::Base
|
5
|
+
def self.valid_parameter_keys
|
6
|
+
[
|
7
|
+
:logger,
|
8
|
+
:instrumentor,
|
9
|
+
:instrumentor_name
|
10
|
+
]
|
11
|
+
end
|
12
|
+
|
5
13
|
def error_call(datum)
|
6
14
|
if datum.has_key?(:instrumentor)
|
7
15
|
datum[:instrumentor].instrument("#{datum[:instrumentor_name]}.error", :error => datum[:error]) do
|
@@ -2,6 +2,14 @@
|
|
2
2
|
module Excon
|
3
3
|
module Middleware
|
4
4
|
class Mock < Excon::Middleware::Base
|
5
|
+
def self.valid_parameter_keys
|
6
|
+
[
|
7
|
+
:allow_unstubbed_requests,
|
8
|
+
:captures,
|
9
|
+
:mock
|
10
|
+
]
|
11
|
+
end
|
12
|
+
|
5
13
|
def request_call(datum)
|
6
14
|
if datum[:mock]
|
7
15
|
# convert File/Tempfile body to string before matching:
|
data/lib/excon/pretty_printer.rb
CHANGED
@@ -9,14 +9,7 @@ module Excon
|
|
9
9
|
datum.delete(:connection)
|
10
10
|
datum.delete(:stack)
|
11
11
|
|
12
|
-
|
13
|
-
datum[:headers] = datum[:headers].dup
|
14
|
-
datum[:headers]['Authorization'] = REDACTED
|
15
|
-
end
|
16
|
-
|
17
|
-
if datum.has_key?(:password)
|
18
|
-
datum[:password] = REDACTED
|
19
|
-
end
|
12
|
+
datum = Utils.redact(datum)
|
20
13
|
end
|
21
14
|
|
22
15
|
indent += 2
|
data/lib/excon/socket.rb
CHANGED
@@ -7,6 +7,23 @@ module Excon
|
|
7
7
|
|
8
8
|
attr_accessor :data
|
9
9
|
|
10
|
+
# read/write drawn from https://github.com/ruby-amqp/bunny/commit/75d9dd79551b31a5dd3d1254c537bad471f108cf
|
11
|
+
CONNECT_RETRY_EXCEPTION_CLASSES = if defined?(IO::EINPROGRESSWaitWritable) # Ruby >= 2.1
|
12
|
+
[Errno::EINPROGRESS, IO::EINPROGRESSWaitWritable]
|
13
|
+
else # Ruby <= 2.0
|
14
|
+
[Errno::EINPROGRESS]
|
15
|
+
end
|
16
|
+
READ_RETRY_EXCEPTION_CLASSES = if defined?(IO::EAGAINWaitReadable) # Ruby >= 2.1
|
17
|
+
[Errno::EAGAIN, Errno::EWOULDBLOCK, IO::WaitReadable, IO::EAGAINWaitReadable, IO::EWOULDBLOCKWaitReadable]
|
18
|
+
else # Ruby <= 2.0
|
19
|
+
[Errno::EAGAIN, Errno::EWOULDBLOCK, IO::WaitReadable]
|
20
|
+
end
|
21
|
+
WRITE_RETRY_EXCEPTION_CLASSES = if defined?(IO::EAGAINWaitWritable) # Ruby >= 2.1
|
22
|
+
[Errno::EAGAIN, Errno::EWOULDBLOCK, IO::WaitWritable, IO::EAGAINWaitWritable, IO::EWOULDBLOCKWaitWritable]
|
23
|
+
else # Ruby <= 2.0
|
24
|
+
[Errno::EAGAIN, Errno::EWOULDBLOCK, IO::WaitWritable]
|
25
|
+
end
|
26
|
+
|
10
27
|
def params
|
11
28
|
Excon.display_warning('Excon::Socket#params is deprecated use Excon::Socket#data instead.')
|
12
29
|
@data
|
@@ -46,7 +63,7 @@ module Excon
|
|
46
63
|
begin
|
47
64
|
buffer << @socket.read_nonblock(1) while buffer[-1] != "\n"
|
48
65
|
buffer
|
49
|
-
rescue
|
66
|
+
rescue *READ_RETRY_EXCEPTION_CLASSES
|
50
67
|
select_with_timeout(@socket, :read) && retry
|
51
68
|
rescue OpenSSL::SSL::SSLError => error
|
52
69
|
if error.message == 'read would block'
|
@@ -106,7 +123,7 @@ module Excon
|
|
106
123
|
if @socket
|
107
124
|
break
|
108
125
|
end
|
109
|
-
|
126
|
+
|
110
127
|
@remote_ip = ip
|
111
128
|
|
112
129
|
# nonblocking connect
|
@@ -128,7 +145,7 @@ module Excon
|
|
128
145
|
socket.connect(sockaddr)
|
129
146
|
end
|
130
147
|
@socket = socket
|
131
|
-
rescue
|
148
|
+
rescue *CONNECT_RETRY_EXCEPTION_CLASSES
|
132
149
|
select_with_timeout(socket, :connect_write)
|
133
150
|
begin
|
134
151
|
socket.connect_nonblock(sockaddr)
|
@@ -151,6 +168,17 @@ module Excon
|
|
151
168
|
::Socket::TCP_NODELAY,
|
152
169
|
true)
|
153
170
|
end
|
171
|
+
|
172
|
+
if @data[:keepalive]
|
173
|
+
if [:SOL_SOCKET, :SO_KEEPALIVE, :SOL_TCP, :TCP_KEEPIDLE, :TCP_KEEPINTVL, :TCP_KEEPCNT].all?{|c| ::Socket.const_defined? c}
|
174
|
+
@socket.setsockopt(::Socket::SOL_SOCKET, ::Socket::SO_KEEPALIVE, true)
|
175
|
+
@socket.setsockopt(::Socket::SOL_TCP, ::Socket::TCP_KEEPIDLE, @data[:keepalive][:time])
|
176
|
+
@socket.setsockopt(::Socket::SOL_TCP, ::Socket::TCP_KEEPINTVL, @data[:keepalive][:intvl])
|
177
|
+
@socket.setsockopt(::Socket::SOL_TCP, ::Socket::TCP_KEEPCNT, @data[:keepalive][:probes])
|
178
|
+
else
|
179
|
+
Excon.display_warning('Excon::Socket keepalive was set, but is not supported by Ruby version.')
|
180
|
+
end
|
181
|
+
end
|
154
182
|
end
|
155
183
|
|
156
184
|
def read_nonblock(max_length)
|
@@ -170,7 +198,7 @@ module Excon
|
|
170
198
|
else
|
171
199
|
raise(error)
|
172
200
|
end
|
173
|
-
rescue
|
201
|
+
rescue *READ_RETRY_EXCEPTION_CLASSES
|
174
202
|
if @read_buffer.empty?
|
175
203
|
# if we didn't read anything, try again...
|
176
204
|
select_with_timeout(@socket, :read) && retry
|
@@ -199,7 +227,7 @@ module Excon
|
|
199
227
|
else
|
200
228
|
raise(error)
|
201
229
|
end
|
202
|
-
rescue
|
230
|
+
rescue *READ_RETRY_EXCEPTION_CLASSES
|
203
231
|
if @read_buffer.empty?
|
204
232
|
select_with_timeout(@socket, :read) && retry
|
205
233
|
end
|
@@ -208,9 +236,7 @@ module Excon
|
|
208
236
|
end
|
209
237
|
|
210
238
|
def write_nonblock(data)
|
211
|
-
|
212
|
-
data.force_encoding('BINARY')
|
213
|
-
end
|
239
|
+
binary_encode(data)
|
214
240
|
loop do
|
215
241
|
written = nil
|
216
242
|
begin
|
@@ -225,7 +251,7 @@ module Excon
|
|
225
251
|
else
|
226
252
|
raise error
|
227
253
|
end
|
228
|
-
rescue OpenSSL::SSL::SSLError,
|
254
|
+
rescue OpenSSL::SSL::SSLError, *WRITE_RETRY_EXCEPTION_CLASSES => error
|
229
255
|
if error.is_a?(OpenSSL::SSL::SSLError) && error.message != 'write would block'
|
230
256
|
raise error
|
231
257
|
else
|
@@ -246,7 +272,7 @@ module Excon
|
|
246
272
|
|
247
273
|
def write_block(data)
|
248
274
|
@socket.write(data)
|
249
|
-
rescue OpenSSL::SSL::SSLError,
|
275
|
+
rescue OpenSSL::SSL::SSLError, *WRITE_RETRY_EXCEPTION_CLASSES => error
|
250
276
|
if error.is_a?(OpenSSL::SSL::SSLError) && error.message != 'write would block'
|
251
277
|
raise error
|
252
278
|
else
|
data/lib/excon/ssl_socket.rb
CHANGED
@@ -27,6 +27,13 @@ module Excon
|
|
27
27
|
if @data[:ssl_version]
|
28
28
|
ssl_context.ssl_version = @data[:ssl_version]
|
29
29
|
end
|
30
|
+
if @data[:ssl_min_version]
|
31
|
+
ssl_context.min_version = @data[:ssl_min_version]
|
32
|
+
end
|
33
|
+
if @data[:ssl_max_version]
|
34
|
+
ssl_context.max_version = @data[:ssl_max_version]
|
35
|
+
end
|
36
|
+
|
30
37
|
|
31
38
|
if @data[:ssl_verify_peer]
|
32
39
|
# turn verification on
|
data/lib/excon/utils.rb
CHANGED
@@ -10,6 +10,12 @@ module Excon
|
|
10
10
|
UNESCAPED = /([#{ Regexp.escape(CONTROL + ' ' + DELIMS + UNWISE + NONASCII) }])/
|
11
11
|
ESCAPED = /%([0-9a-fA-F]{2})/
|
12
12
|
|
13
|
+
def binary_encode(string)
|
14
|
+
if FORCE_ENC && string.encoding != Encoding::ASCII_8BIT
|
15
|
+
string.force_encoding('BINARY')
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
13
19
|
def connection_uri(datum = @data)
|
14
20
|
unless datum
|
15
21
|
raise ArgumentError, '`datum` must be given unless called on a Connection'
|
@@ -21,6 +27,19 @@ module Excon
|
|
21
27
|
end
|
22
28
|
end
|
23
29
|
|
30
|
+
# Redact sensitive info from provided data
|
31
|
+
def redact(datum)
|
32
|
+
datum = datum.dup
|
33
|
+
if datum.has_key?(:headers) && datum[:headers].has_key?('Authorization')
|
34
|
+
datum[:headers] = datum[:headers].dup
|
35
|
+
datum[:headers]['Authorization'] = REDACTED
|
36
|
+
end
|
37
|
+
if datum.has_key?(:password)
|
38
|
+
datum[:password] = REDACTED
|
39
|
+
end
|
40
|
+
datum
|
41
|
+
end
|
42
|
+
|
24
43
|
def request_uri(datum)
|
25
44
|
connection_uri(datum) + datum[:path] + query_string(datum)
|
26
45
|
end
|
@@ -59,7 +78,7 @@ module Excon
|
|
59
78
|
def split_header_value(str)
|
60
79
|
return [] if str.nil?
|
61
80
|
str = str.dup.strip
|
62
|
-
str
|
81
|
+
binary_encode(str)
|
63
82
|
str.scan(%r'\G((?:"(?:\\.|[^"])+?"|[^",]+)+)
|
64
83
|
(?:,\s*|\Z)'xn).flatten
|
65
84
|
end
|
@@ -67,21 +86,21 @@ module Excon
|
|
67
86
|
# Escapes HTTP reserved and unwise characters in +str+
|
68
87
|
def escape_uri(str)
|
69
88
|
str = str.dup
|
70
|
-
str
|
89
|
+
binary_encode(str)
|
71
90
|
str.gsub(UNESCAPED) { "%%%02X" % $1[0].ord }
|
72
91
|
end
|
73
92
|
|
74
93
|
# Unescapes HTTP reserved and unwise characters in +str+
|
75
94
|
def unescape_uri(str)
|
76
95
|
str = str.dup
|
77
|
-
str
|
96
|
+
binary_encode(str)
|
78
97
|
str.gsub(ESCAPED) { $1.hex.chr }
|
79
98
|
end
|
80
99
|
|
81
100
|
# Unescape form encoded values in +str+
|
82
101
|
def unescape_form(str)
|
83
102
|
str = str.dup
|
84
|
-
str
|
103
|
+
binary_encode(str)
|
85
104
|
str.gsub!(/\+/, ' ')
|
86
105
|
str.gsub(ESCAPED) { $1.hex.chr }
|
87
106
|
end
|
data/lib/excon/version.rb
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
require 'spec_helper'
|
2
2
|
|
3
3
|
describe Excon::Test::Server do
|
4
|
-
|
4
|
+
|
5
5
|
context 'when the web server is webrick' do
|
6
6
|
it_should_behave_like "a excon test server", :webrick, 'basic.ru'
|
7
7
|
end
|
@@ -23,6 +23,6 @@ describe Excon::Test::Server do
|
|
23
23
|
end
|
24
24
|
|
25
25
|
context 'when the web server is a executable' do
|
26
|
-
it_should_behave_like "a excon test server", :exec, '
|
26
|
+
it_should_behave_like "a excon test server", :exec, 'good_ipv4.rb'
|
27
27
|
end
|
28
28
|
end
|
@@ -1,25 +1,17 @@
|
|
1
1
|
require "spec_helper"
|
2
2
|
|
3
3
|
describe Excon::Connection do
|
4
|
+
include_context('stubs')
|
4
5
|
context "when speaking to a UNIX socket" do
|
5
6
|
context "Host header handling" do
|
6
7
|
before do
|
7
|
-
|
8
|
+
Excon.stub do |req|
|
8
9
|
{
|
9
10
|
body: req[:headers].to_json,
|
10
11
|
status: 200,
|
11
12
|
}
|
12
13
|
end
|
13
|
-
|
14
|
-
@original_mock = Excon.defaults[:mock]
|
15
|
-
Excon.defaults[:mock] = true
|
16
|
-
Excon.stub({}, responder)
|
17
|
-
end
|
18
|
-
|
19
|
-
after do
|
20
|
-
Excon.defaults[:mock] = @original_mock
|
21
14
|
end
|
22
|
-
|
23
15
|
it "sends an empty Host= by default" do
|
24
16
|
conn = Excon::Connection.new(
|
25
17
|
scheme: "unix",
|
@@ -0,0 +1,80 @@
|
|
1
|
+
describe Excon::Connection do
|
2
|
+
include_context('stubs')
|
3
|
+
describe 'validating parameters' do
|
4
|
+
class FooMiddleware < Excon::Middleware::Base
|
5
|
+
def self.valid_parameter_keys
|
6
|
+
[:foo]
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
let(:foo_stack) do
|
11
|
+
Excon.defaults[:middlewares] + [FooMiddleware]
|
12
|
+
end
|
13
|
+
|
14
|
+
def expect_parameter_warning(validation, key)
|
15
|
+
expect { yield }.to raise_error(Excon::Error::Warning, "Invalid Excon #{validation} keys: #{key.inspect}")
|
16
|
+
end
|
17
|
+
|
18
|
+
context 'with default middleware' do
|
19
|
+
it 'Connection.new warns on invalid parameter keys' do
|
20
|
+
expect_parameter_warning('connection', :foo) do
|
21
|
+
Excon.new('http://foo', :foo => :bar)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
it 'Connection#request warns on invalid parameter keys' do
|
26
|
+
conn = Excon.new('http://foo')
|
27
|
+
expect_parameter_warning('request', :foo) do
|
28
|
+
conn.request(:foo => :bar)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
context 'with custom middleware at instantiation' do
|
34
|
+
it 'Connection.new accepts parameters that are valid for the provided middleware' do
|
35
|
+
Excon.new('http://foo', :foo => :bar, :middlewares => foo_stack)
|
36
|
+
end
|
37
|
+
|
38
|
+
it 'Connection.new warns on parameters that are not valid for the provided middleware' do
|
39
|
+
expect_parameter_warning('connection', :bar) do
|
40
|
+
Excon.new('http://foo', :bar => :baz, :middlewares => foo_stack)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
it 'Connection#request accepts parameters that are valid for the provided middleware' do
|
45
|
+
Excon.stub({}, {})
|
46
|
+
conn = Excon.new('http://foo', :middlewares => foo_stack)
|
47
|
+
conn.request(:foo => :bar)
|
48
|
+
end
|
49
|
+
|
50
|
+
it 'Connection#request warns on parameters that are not valid for the provided middleware' do
|
51
|
+
conn = Excon.new('http://foo', :middlewares => foo_stack)
|
52
|
+
expect_parameter_warning('request', :bar) do
|
53
|
+
conn.request(:bar => :baz)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
context 'with custom middleware at request time' do
|
59
|
+
it 'Connection#request accepts parameters that are valid for the provided middleware' do
|
60
|
+
Excon.stub({}, {})
|
61
|
+
conn = Excon.new('http://foo')
|
62
|
+
conn.request(:foo => :bar, :middlewares => foo_stack)
|
63
|
+
end
|
64
|
+
|
65
|
+
it 'Connection#request warns on parameters that are not valid for the request middleware' do
|
66
|
+
conn = Excon.new('http://foo')
|
67
|
+
expect_parameter_warning('request', :bar) do
|
68
|
+
conn.request(:bar => :baz, :middlewares => foo_stack)
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
it 'Connection#request warns on parameters from instantiation that are not valid for the request middleware' do
|
73
|
+
conn = Excon.new('http://foo', :foo => :bar, :middlewares => foo_stack)
|
74
|
+
expect_parameter_warning('connection', :foo) do
|
75
|
+
conn.request(:middlewares => Excon.defaults[:middlewares])
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
data/spec/spec_helper.rb
CHANGED
@@ -91,8 +91,10 @@ shared_examples_for 'a basic client' do |url = 'http://127.0.0.1:9292', opts = {
|
|
91
91
|
data = []
|
92
92
|
it 'yields with a chunk, remaining length, and total length' do
|
93
93
|
expect do
|
94
|
-
|
95
|
-
|
94
|
+
silence_warnings do
|
95
|
+
conn.request(method: :get, path: '/content-length/100') do |chunk, remaining_length, total_length|
|
96
|
+
data = [chunk, remaining_length, total_length]
|
97
|
+
end
|
96
98
|
end
|
97
99
|
end.to_not raise_error
|
98
100
|
end
|
@@ -163,8 +165,8 @@ shared_examples_for 'a basic client' do |url = 'http://127.0.0.1:9292', opts = {
|
|
163
165
|
end
|
164
166
|
|
165
167
|
context 'when a string is the body paramter' do
|
166
|
-
it 'does not change the
|
167
|
-
skip unless RUBY_VERSION >= '
|
168
|
+
it 'does not change the enconding of the body' do
|
169
|
+
skip unless RUBY_VERSION >= '2.0'
|
168
170
|
|
169
171
|
string_body = '¥£€'
|
170
172
|
expect do
|
@@ -1,29 +1,27 @@
|
|
1
1
|
Shindo.tests('Excon basics (Authorization data redacted)') do
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
conn = nil
|
2
|
+
cases = [
|
3
|
+
['user & pass', 'http://user1:pass1@foo.com/', 'Basic dXNlcjE6cGFzczE='],
|
4
|
+
['email & pass', 'http://foo%40bar.com:pass1@foo.com/', 'Basic Zm9vQGJhci5jb206cGFzczE='],
|
5
|
+
['user no pass', 'http://three_user@foo.com/', 'Basic dGhyZWVfdXNlcjo='],
|
6
|
+
['pass no user', 'http://:derppass@foo.com/', 'Basic OmRlcnBwYXNz']
|
7
|
+
]
|
8
|
+
cases.each do |desc,url,auth_header|
|
9
|
+
conn = nil
|
11
10
|
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
if conn.data[:password]
|
18
|
-
test("password param concealed for #{desc}") do
|
19
|
-
!conn.inspect.include?(conn.data[:password])
|
20
|
-
end
|
21
|
-
end
|
11
|
+
test("authorization header concealed for #{desc}") do
|
12
|
+
conn = Excon.new(url)
|
13
|
+
!conn.inspect.include?(auth_header)
|
14
|
+
end
|
22
15
|
|
23
|
-
|
24
|
-
|
16
|
+
if conn.data[:password]
|
17
|
+
test("password param concealed for #{desc}") do
|
18
|
+
!conn.inspect.include?(conn.data[:password])
|
25
19
|
end
|
20
|
+
end
|
26
21
|
|
22
|
+
test("password param remains correct for #{desc}") do
|
23
|
+
conn.data[:password] == URI.parse(url).password
|
27
24
|
end
|
25
|
+
|
28
26
|
end
|
29
27
|
end
|