rest-builder 0.9.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +2 -0
- data/.gitmodules +6 -0
- data/.travis.yml +14 -0
- data/CHANGES.md +5 -0
- data/Gemfile +24 -0
- data/README.md +577 -0
- data/Rakefile +21 -0
- data/lib/rest-builder.rb +27 -0
- data/lib/rest-builder/builder.rb +164 -0
- data/lib/rest-builder/client.rb +282 -0
- data/lib/rest-builder/engine.rb +57 -0
- data/lib/rest-builder/engine/dry.rb +11 -0
- data/lib/rest-builder/engine/http-client.rb +46 -0
- data/lib/rest-builder/error.rb +4 -0
- data/lib/rest-builder/event_source.rb +137 -0
- data/lib/rest-builder/middleware.rb +147 -0
- data/lib/rest-builder/payload.rb +173 -0
- data/lib/rest-builder/promise.rb +35 -0
- data/lib/rest-builder/test.rb +26 -0
- data/lib/rest-builder/version.rb +4 -0
- data/rest-builder.gemspec +73 -0
- data/task/README.md +54 -0
- data/task/gemgem.rb +316 -0
- data/test/test_builder.rb +45 -0
- data/test/test_client.rb +212 -0
- data/test/test_event_source.rb +152 -0
- data/test/test_future.rb +21 -0
- data/test/test_httpclient.rb +118 -0
- data/test/test_payload.rb +205 -0
- metadata +129 -0
@@ -0,0 +1,57 @@
|
|
1
|
+
|
2
|
+
require 'rest-builder/promise'
|
3
|
+
require 'rest-builder/payload'
|
4
|
+
require 'rest-builder/middleware'
|
5
|
+
|
6
|
+
module RestBuilder
|
7
|
+
class Engine
|
8
|
+
def self.members; [:config_engine]; end
|
9
|
+
include Middleware
|
10
|
+
|
11
|
+
def call env, &k
|
12
|
+
promise = Promise.new(env[TIMER])
|
13
|
+
req = env.merge(REQUEST_URI => request_uri(env), PROMISE => promise)
|
14
|
+
|
15
|
+
promise.then do |result|
|
16
|
+
case result
|
17
|
+
when Exception
|
18
|
+
req.merge(FAIL => env[FAIL] + [result])
|
19
|
+
else
|
20
|
+
req.merge(result)
|
21
|
+
end
|
22
|
+
end.then(&k)
|
23
|
+
|
24
|
+
pool_size = env[CLIENT].class.pool_size
|
25
|
+
if pool_size < 0
|
26
|
+
promise.call{ request(req) }
|
27
|
+
elsif pool_size == 0
|
28
|
+
promise.defer{ request(req) }
|
29
|
+
else
|
30
|
+
promise.defer(env[CLIENT].class.thread_pool){ request(req) }
|
31
|
+
end
|
32
|
+
|
33
|
+
req.merge(promise.future_response)
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
def payload_and_headers env
|
38
|
+
if has_payload?(env)
|
39
|
+
Payload.generate_with_headers(env[REQUEST_PAYLOAD],
|
40
|
+
env[REQUEST_HEADERS])
|
41
|
+
else
|
42
|
+
[{}, env[REQUEST_HEADERS]]
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def normalize_headers headers
|
47
|
+
headers.inject({}){ |r, (k, v)|
|
48
|
+
r[k.to_s.upcase.tr('-', '_')] = if v.kind_of?(Array) && v.size == 1
|
49
|
+
v.first
|
50
|
+
else
|
51
|
+
v
|
52
|
+
end
|
53
|
+
r
|
54
|
+
}
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
|
2
|
+
require 'httpclient'
|
3
|
+
# httpclient would require something (cookie manager) while initialized,
|
4
|
+
# so we should try to force requiring them to avoid require deadlock!
|
5
|
+
HTTPClient.new
|
6
|
+
|
7
|
+
require 'rest-builder/engine'
|
8
|
+
|
9
|
+
module RestBuilder
|
10
|
+
class HttpClient < Engine
|
11
|
+
private
|
12
|
+
def request env
|
13
|
+
client = ::HTTPClient.new
|
14
|
+
client.cookie_manager = nil
|
15
|
+
client.follow_redirect_count = 0
|
16
|
+
client.transparent_gzip_decompression = true
|
17
|
+
config = config_engine(env) and config.call(client)
|
18
|
+
payload, headers = payload_and_headers(env)
|
19
|
+
|
20
|
+
if env[HIJACK]
|
21
|
+
request_async(client, payload, headers, env)
|
22
|
+
else
|
23
|
+
request_sync(client, payload, headers, env)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def request_sync client, payload, headers, env
|
28
|
+
res = client.request(env[REQUEST_METHOD], env[REQUEST_URI], nil,
|
29
|
+
payload, {'User-Agent' => 'Ruby'}.merge(headers))
|
30
|
+
|
31
|
+
{RESPONSE_STATUS => res.status,
|
32
|
+
RESPONSE_HEADERS => normalize_headers(res.header.all),
|
33
|
+
RESPONSE_BODY => res.content}
|
34
|
+
end
|
35
|
+
|
36
|
+
def request_async client, payload, headers, env
|
37
|
+
res = client.request_async(env[REQUEST_METHOD], env[REQUEST_URI], nil,
|
38
|
+
payload, {'User-Agent' => 'Ruby'}.merge(headers)).pop
|
39
|
+
|
40
|
+
{RESPONSE_STATUS => res.status,
|
41
|
+
RESPONSE_HEADERS => normalize_headers(res.header.all),
|
42
|
+
RESPONSE_BODY => '',
|
43
|
+
RESPONSE_SOCKET => res.content}
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,137 @@
|
|
1
|
+
|
2
|
+
require 'thread'
|
3
|
+
require 'rest-builder/error'
|
4
|
+
|
5
|
+
module RestBuilder
|
6
|
+
class EventSource < Struct.new(:client, :path, :query, :opts, :socket)
|
7
|
+
READ_WAIT = 35
|
8
|
+
|
9
|
+
def start
|
10
|
+
self.mutex = Mutex.new
|
11
|
+
self.condv = ConditionVariable.new
|
12
|
+
@onopen ||= nil
|
13
|
+
@onmessage ||= nil
|
14
|
+
@onerror ||= nil
|
15
|
+
@onreconnect ||= nil
|
16
|
+
@closed ||= false
|
17
|
+
reconnect
|
18
|
+
self
|
19
|
+
end
|
20
|
+
|
21
|
+
def closed?
|
22
|
+
!!(socket && socket.closed?) || @closed
|
23
|
+
end
|
24
|
+
|
25
|
+
def close
|
26
|
+
socket && socket.close
|
27
|
+
rescue IOError
|
28
|
+
end
|
29
|
+
|
30
|
+
def wait
|
31
|
+
raise Error.new("Not yet started for: #{self}") unless mutex
|
32
|
+
mutex.synchronize{ condv.wait(mutex) until closed? } unless closed?
|
33
|
+
self
|
34
|
+
end
|
35
|
+
|
36
|
+
def onopen sock=nil, &cb
|
37
|
+
if block_given?
|
38
|
+
@onopen = cb
|
39
|
+
else
|
40
|
+
self.socket = sock # for you to track the socket
|
41
|
+
@onopen.call(sock) if @onopen
|
42
|
+
onmessage_for(sock)
|
43
|
+
end
|
44
|
+
self
|
45
|
+
rescue Exception => e
|
46
|
+
begin # close the socket since we're going to stop anyway
|
47
|
+
sock.close # if we don't close it, client might wait forever
|
48
|
+
rescue IOError
|
49
|
+
end
|
50
|
+
# let the client has a chance to handle this, and make signal
|
51
|
+
onerror(e, sock)
|
52
|
+
end
|
53
|
+
|
54
|
+
def onmessage event=nil, data=nil, sock=nil, &cb
|
55
|
+
if block_given?
|
56
|
+
@onmessage = cb
|
57
|
+
elsif @onmessage
|
58
|
+
@onmessage.call(event, data, sock)
|
59
|
+
end
|
60
|
+
self
|
61
|
+
end
|
62
|
+
|
63
|
+
# would also be called upon closing, would always be called at least once
|
64
|
+
def onerror error=nil, sock=nil, &cb
|
65
|
+
if block_given?
|
66
|
+
@onerror = cb
|
67
|
+
else
|
68
|
+
begin
|
69
|
+
@onerror.call(error, sock) if @onerror
|
70
|
+
onreconnect(error, sock)
|
71
|
+
rescue Exception
|
72
|
+
mutex.synchronize do
|
73
|
+
@closed = true
|
74
|
+
condv.signal # so we can't be reconnecting, need to try to unblock
|
75
|
+
end
|
76
|
+
raise
|
77
|
+
end
|
78
|
+
end
|
79
|
+
self
|
80
|
+
end
|
81
|
+
|
82
|
+
# would be called upon closing,
|
83
|
+
# and would try to reconnect if a callback is set and return true
|
84
|
+
def onreconnect error=nil, sock=nil, &cb
|
85
|
+
if block_given?
|
86
|
+
@onreconnect = cb
|
87
|
+
elsif closed? && @onreconnect && @onreconnect.call(error, sock)
|
88
|
+
reconnect
|
89
|
+
else
|
90
|
+
mutex.synchronize do
|
91
|
+
@closed = true
|
92
|
+
condv.signal # we could be closing, let's try to unblock it
|
93
|
+
end
|
94
|
+
end
|
95
|
+
self
|
96
|
+
end
|
97
|
+
|
98
|
+
protected
|
99
|
+
attr_accessor :mutex, :condv
|
100
|
+
|
101
|
+
private
|
102
|
+
# called in requesting thread after the request is done
|
103
|
+
def onmessage_for sock
|
104
|
+
while IO.select([sock], [], [], READ_WAIT)
|
105
|
+
event = sock.readline("\n\n").split("\n").inject({}) do |r, i|
|
106
|
+
k, v = i.split(': ', 2)
|
107
|
+
r[k] = v
|
108
|
+
r
|
109
|
+
end
|
110
|
+
onmessage(event['event'], event['data'], sock)
|
111
|
+
end
|
112
|
+
close_sock(sock)
|
113
|
+
onerror(EOFError.new, sock)
|
114
|
+
rescue IOError, SystemCallError => e
|
115
|
+
close_sock(sock)
|
116
|
+
onerror(e, sock)
|
117
|
+
end
|
118
|
+
|
119
|
+
def close_sock sock
|
120
|
+
sock.close
|
121
|
+
rescue IOError => e
|
122
|
+
onerror(e, sock)
|
123
|
+
end
|
124
|
+
|
125
|
+
def reconnect
|
126
|
+
o = {REQUEST_HEADERS => {'Accept' => 'text/event-stream'},
|
127
|
+
HIJACK => true}.merge(opts)
|
128
|
+
client.get(path, query, o) do |sock|
|
129
|
+
if sock.nil? || sock.kind_of?(Exception)
|
130
|
+
onerror(sock)
|
131
|
+
else
|
132
|
+
onopen(sock)
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
137
|
+
end
|
@@ -0,0 +1,147 @@
|
|
1
|
+
|
2
|
+
module RestBuilder
|
3
|
+
module Middleware
|
4
|
+
METHODS_WITH_PAYLOAD = [:post, :put, :patch]
|
5
|
+
|
6
|
+
def self.included mod
|
7
|
+
mod.send(:attr_reader, :app)
|
8
|
+
mem = if mod.respond_to?(:members) then mod.members else [] end
|
9
|
+
src = mem.map{ |member| <<-RUBY }
|
10
|
+
attr_writer :#{member}
|
11
|
+
def #{member} env
|
12
|
+
if env.key?('#{member}')
|
13
|
+
env['#{member}']
|
14
|
+
else
|
15
|
+
@#{member}
|
16
|
+
end
|
17
|
+
end
|
18
|
+
RUBY
|
19
|
+
args = [:app] + mem
|
20
|
+
para_list = args.map{ |a| "#{a}=nil"}.join(', ')
|
21
|
+
args_list = args .join(', ')
|
22
|
+
ivar_list = args.map{ |a| "@#{a}" }.join(', ')
|
23
|
+
src << <<-RUBY
|
24
|
+
def initialize #{para_list}
|
25
|
+
#{ivar_list} = #{args_list}
|
26
|
+
end
|
27
|
+
RUBY
|
28
|
+
accessor = Module.new
|
29
|
+
accessor.module_eval(src.join("\n"), __FILE__, __LINE__)
|
30
|
+
mod.const_set(:Accessor, accessor)
|
31
|
+
mod.send(:include, accessor)
|
32
|
+
end
|
33
|
+
|
34
|
+
def call env, &k; app.call(env, &(k || :itself.to_proc)); end
|
35
|
+
def fail env, obj
|
36
|
+
if obj
|
37
|
+
env.merge(FAIL => (env[FAIL] || []) + [obj])
|
38
|
+
else
|
39
|
+
env
|
40
|
+
end
|
41
|
+
end
|
42
|
+
def log env, obj
|
43
|
+
if obj
|
44
|
+
env.merge(LOG => (env[LOG] || []) + [obj])
|
45
|
+
else
|
46
|
+
env
|
47
|
+
end
|
48
|
+
end
|
49
|
+
def run a=app
|
50
|
+
if a.respond_to?(:app) && a.app
|
51
|
+
run(a.app)
|
52
|
+
else
|
53
|
+
a
|
54
|
+
end
|
55
|
+
end
|
56
|
+
def error_callback res, err
|
57
|
+
res[CLIENT].error_callback.call(err) if
|
58
|
+
res[CLIENT] && res[CLIENT].error_callback
|
59
|
+
end
|
60
|
+
def give_promise res
|
61
|
+
res[CLIENT].give_promise(res) if res[CLIENT]
|
62
|
+
end
|
63
|
+
|
64
|
+
module_function
|
65
|
+
def request_uri env
|
66
|
+
# compacting the hash
|
67
|
+
if (query = (env[REQUEST_QUERY] || {}).select{ |k, v| v }).empty?
|
68
|
+
env[REQUEST_PATH].to_s
|
69
|
+
else
|
70
|
+
q = if env[REQUEST_PATH] =~ /\?/ then '&' else '?' end
|
71
|
+
"#{env[REQUEST_PATH]}#{q}#{percent_encode(query)}"
|
72
|
+
end
|
73
|
+
end
|
74
|
+
public :request_uri
|
75
|
+
|
76
|
+
def percent_encode query
|
77
|
+
query.sort.map{ |(k, v)|
|
78
|
+
if v.kind_of?(Array)
|
79
|
+
v.map{ |vv| "#{escape(k.to_s)}=#{escape(vv.to_s)}" }.join('&')
|
80
|
+
else
|
81
|
+
"#{escape(k.to_s)}=#{escape(v.to_s)}"
|
82
|
+
end
|
83
|
+
}.join('&')
|
84
|
+
end
|
85
|
+
public :percent_encode
|
86
|
+
|
87
|
+
UNRESERVED = /[^a-zA-Z0-9\-\.\_\~]+/
|
88
|
+
def escape string
|
89
|
+
string.gsub(UNRESERVED) do |s|
|
90
|
+
"%#{s.unpack('H2' * s.bytesize).join('%')}".upcase
|
91
|
+
end
|
92
|
+
end
|
93
|
+
public :escape
|
94
|
+
|
95
|
+
def has_payload? env
|
96
|
+
METHODS_WITH_PAYLOAD.include?(env[REQUEST_METHOD])
|
97
|
+
end
|
98
|
+
public :has_payload?
|
99
|
+
|
100
|
+
def contain_binary? payload
|
101
|
+
return false unless payload
|
102
|
+
return true if payload.respond_to?(:read)
|
103
|
+
return true if payload.find{ |k, v|
|
104
|
+
# if payload is an array, then v would be nil
|
105
|
+
(v || k).respond_to?(:read) ||
|
106
|
+
# if v is an array, it could contain binary data
|
107
|
+
(v.kind_of?(Array) && v.any?{ |vv| vv.respond_to?(:read) }) }
|
108
|
+
return false
|
109
|
+
end
|
110
|
+
public :contain_binary?
|
111
|
+
|
112
|
+
def string_keys hash
|
113
|
+
hash.inject({}){ |r, (k, v)|
|
114
|
+
if v.kind_of?(Hash)
|
115
|
+
r[k.to_s] = case k.to_s
|
116
|
+
when REQUEST_QUERY, REQUEST_PAYLOAD, REQUEST_HEADERS
|
117
|
+
string_keys(v)
|
118
|
+
else; v
|
119
|
+
end
|
120
|
+
else
|
121
|
+
r[k.to_s] = v
|
122
|
+
end
|
123
|
+
r
|
124
|
+
}
|
125
|
+
end
|
126
|
+
public :string_keys
|
127
|
+
|
128
|
+
# this method is intended to merge payloads if they are non-empty hashes,
|
129
|
+
# but prefer the right most one if they are not hashes.
|
130
|
+
def merge_hash *hashes
|
131
|
+
hashes.reverse_each.inject do |r, i|
|
132
|
+
if r.kind_of?(Hash)
|
133
|
+
if i.kind_of?(Hash)
|
134
|
+
Middleware.string_keys(i).merge(Middleware.string_keys(r))
|
135
|
+
elsif r.empty?
|
136
|
+
i # prefer non-empty ones
|
137
|
+
else
|
138
|
+
r # don't try to merge non-hashes
|
139
|
+
end
|
140
|
+
else
|
141
|
+
r
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
145
|
+
public :merge_hash
|
146
|
+
end
|
147
|
+
end
|
@@ -0,0 +1,173 @@
|
|
1
|
+
|
2
|
+
# stolen and modified from rest-client
|
3
|
+
|
4
|
+
require 'stringio'
|
5
|
+
require 'tempfile'
|
6
|
+
|
7
|
+
require 'rest-builder/error'
|
8
|
+
require 'rest-builder/middleware'
|
9
|
+
|
10
|
+
begin
|
11
|
+
require 'mime/types/columnar'
|
12
|
+
rescue LoadError
|
13
|
+
require 'mime/types'
|
14
|
+
end
|
15
|
+
|
16
|
+
module RestBuilder
|
17
|
+
class Payload
|
18
|
+
def self.generate_with_headers payload, headers
|
19
|
+
h = if p = generate(payload)
|
20
|
+
p.headers.merge(headers)
|
21
|
+
else
|
22
|
+
headers
|
23
|
+
end
|
24
|
+
[p, h]
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.generate payload
|
28
|
+
if payload.respond_to?(:read)
|
29
|
+
Streamed.new(payload)
|
30
|
+
|
31
|
+
elsif payload.kind_of?(String)
|
32
|
+
StreamedString.new(payload)
|
33
|
+
|
34
|
+
elsif payload.kind_of?(Hash)
|
35
|
+
if payload.empty?
|
36
|
+
nil
|
37
|
+
|
38
|
+
elsif Middleware.contain_binary?(payload)
|
39
|
+
Multipart.new(payload)
|
40
|
+
|
41
|
+
else
|
42
|
+
UrlEncoded.new(payload)
|
43
|
+
end
|
44
|
+
|
45
|
+
else
|
46
|
+
raise Error.new("Payload should be either String, Hash, or" \
|
47
|
+
" responding to `read', but: #{payload.inspect}")
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
# Payload API
|
52
|
+
attr_reader :io
|
53
|
+
alias_method :to_io, :io
|
54
|
+
|
55
|
+
def initialize payload; @io = payload ; end
|
56
|
+
def read bytes=nil; io.read(bytes) ; end
|
57
|
+
def close ; io.close unless closed?; end
|
58
|
+
def closed? ; io.closed? ; end
|
59
|
+
def headers ; {} ; end
|
60
|
+
|
61
|
+
def size
|
62
|
+
if io.respond_to?(:size)
|
63
|
+
io.size
|
64
|
+
elsif io.respond_to?(:stat)
|
65
|
+
io.stat.size
|
66
|
+
else
|
67
|
+
0
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
class Streamed < Payload
|
72
|
+
def headers
|
73
|
+
{'Content-Length' => size.to_s}
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
class StreamedString < Streamed
|
78
|
+
def initialize payload
|
79
|
+
super(StringIO.new(payload))
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
class UrlEncoded < StreamedString
|
84
|
+
def initialize payload
|
85
|
+
super(Middleware.percent_encode(payload))
|
86
|
+
end
|
87
|
+
|
88
|
+
def headers
|
89
|
+
super.merge('Content-Type' => 'application/x-www-form-urlencoded')
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
class Multipart < Streamed
|
94
|
+
EOL = "\r\n"
|
95
|
+
|
96
|
+
def initialize payload
|
97
|
+
super(Tempfile.new("rest-core.payload.#{boundary}"))
|
98
|
+
|
99
|
+
io.binmode
|
100
|
+
|
101
|
+
payload.each_with_index do |(k, v), i|
|
102
|
+
if v.kind_of?(Array)
|
103
|
+
v.each{ |vv| part(k, vv) }
|
104
|
+
else
|
105
|
+
part(k, v)
|
106
|
+
end
|
107
|
+
end
|
108
|
+
io.write("--#{boundary}--#{EOL}")
|
109
|
+
io.rewind
|
110
|
+
end
|
111
|
+
|
112
|
+
def part k, v
|
113
|
+
io.write("--#{boundary}#{EOL}Content-Disposition: form-data")
|
114
|
+
io.write("; name=\"#{k}\"") if k
|
115
|
+
if v.respond_to?(:read)
|
116
|
+
part_binary(k, v)
|
117
|
+
else
|
118
|
+
part_plantext(k, v)
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
def part_plantext k, v
|
123
|
+
io.write("#{EOL}#{EOL}#{v}#{EOL}")
|
124
|
+
end
|
125
|
+
|
126
|
+
def part_binary k, v
|
127
|
+
if v.respond_to?(:original_filename) # Rails
|
128
|
+
io.write("; filename=\"#{v.original_filename}\"#{EOL}")
|
129
|
+
elsif v.respond_to?(:path) # files
|
130
|
+
io.write("; filename=\"#{File.basename(v.path)}\"#{EOL}")
|
131
|
+
else # io
|
132
|
+
io.write("; filename=\"#{k}\"#{EOL}")
|
133
|
+
end
|
134
|
+
|
135
|
+
# supply your own content type for regular files, will you?
|
136
|
+
if v.respond_to?(:content_type) # Rails
|
137
|
+
io.write("Content-Type: #{v.content_type}#{EOL}#{EOL}")
|
138
|
+
elsif v.respond_to?(:path) && type = mime_type(v.path) # files
|
139
|
+
io.write("Content-Type: #{type}#{EOL}#{EOL}")
|
140
|
+
else
|
141
|
+
io.write(EOL)
|
142
|
+
end
|
143
|
+
|
144
|
+
while data = v.read(8192)
|
145
|
+
io.write(data)
|
146
|
+
end
|
147
|
+
|
148
|
+
io.write(EOL)
|
149
|
+
|
150
|
+
ensure
|
151
|
+
v.close if v.respond_to?(:close)
|
152
|
+
end
|
153
|
+
|
154
|
+
def mime_type path
|
155
|
+
mime = MIME::Types.type_for(path)
|
156
|
+
mime.first && mime.first.content_type
|
157
|
+
end
|
158
|
+
|
159
|
+
def boundary
|
160
|
+
@boundary ||= rand(1_000_000).to_s
|
161
|
+
end
|
162
|
+
|
163
|
+
def headers
|
164
|
+
super.merge('Content-Type' =>
|
165
|
+
"multipart/form-data; boundary=#{boundary}")
|
166
|
+
end
|
167
|
+
|
168
|
+
def close
|
169
|
+
io.close! unless io.closed?
|
170
|
+
end
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|