rest-builder 0.9.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.
- 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
|