ftw 0.0.6 → 0.0.7
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.
- data/README.md +14 -4
- data/lib/ftw.rb +1 -0
- data/lib/ftw/agent.rb +34 -17
- data/lib/ftw/connection.rb +109 -23
- data/lib/ftw/cookies.rb +16 -6
- data/lib/ftw/crlf.rb +3 -1
- data/lib/ftw/dns.rb +4 -5
- data/lib/ftw/namespace.rb +2 -0
- data/lib/ftw/pool.rb +7 -2
- data/lib/ftw/protocol.rb +60 -0
- data/lib/ftw/request.rb +4 -31
- data/lib/ftw/server.rb +110 -0
- data/lib/ftw/singleton.rb +13 -0
- data/lib/ftw/version.rb +3 -1
- data/lib/ftw/websocket.rb +11 -64
- data/lib/ftw/websocket/constants.rb +28 -0
- data/lib/ftw/websocket/parser.rb +51 -12
- data/lib/ftw/websocket/rack.rb +77 -0
- data/lib/ftw/websocket/writer.rb +114 -0
- data/lib/rack/handler/ftw.rb +153 -0
- data/test/all.rb +10 -2
- data/test/docs.rb +42 -0
- metadata +27 -19
data/lib/ftw/websocket/parser.rb
CHANGED
@@ -62,6 +62,8 @@ class FTW::WebSocket::Parser
|
|
62
62
|
def transition(state, next_length)
|
63
63
|
@logger.debug("Transitioning", :transition => state, :nextlen => next_length)
|
64
64
|
@state = state
|
65
|
+
# TODO(sissel): Assert this self.respond_to?(state)
|
66
|
+
# TODO(sissel): Assert next_length is a number
|
65
67
|
need(next_length)
|
66
68
|
end # def transition
|
67
69
|
|
@@ -77,8 +79,7 @@ class FTW::WebSocket::Parser
|
|
77
79
|
while have?(@need)
|
78
80
|
value = send(@state)
|
79
81
|
# Return if our state yields a value.
|
80
|
-
|
81
|
-
#yield value if !value.nil? and block_given?
|
82
|
+
yield value if !value.nil? and block_given?
|
82
83
|
end
|
83
84
|
return nil
|
84
85
|
end # def <<
|
@@ -111,7 +112,7 @@ class FTW::WebSocket::Parser
|
|
111
112
|
# |I|S|S|S| (4)
|
112
113
|
# |N|V|V|V|
|
113
114
|
# | |1|2|3|
|
114
|
-
byte = get.bytes.first
|
115
|
+
byte = get(@need).bytes.first
|
115
116
|
@opcode = byte & 0xF # last 4 bites
|
116
117
|
@fin = (byte & 0x80 == 0x80)# first bit
|
117
118
|
|
@@ -127,8 +128,8 @@ class FTW::WebSocket::Parser
|
|
127
128
|
# State: mask_and_payload_init
|
128
129
|
# See: http://tools.ietf.org/html/rfc6455#section-5.2
|
129
130
|
def mask_and_payload_init
|
130
|
-
byte = get.bytes.first
|
131
|
-
@
|
131
|
+
byte = get(@need).bytes.first
|
132
|
+
@masked = (byte & 0x80) == 0x80 # first bit (msb)
|
132
133
|
@payload_length = byte & 0x7F # remaining bits are the length
|
133
134
|
case @payload_length
|
134
135
|
when 126 # 2 byte, unsigned value is the payload length
|
@@ -136,9 +137,15 @@ class FTW::WebSocket::Parser
|
|
136
137
|
when 127 # 8 byte, unsigned value is the payload length
|
137
138
|
transition(:extended_payload_length, 8)
|
138
139
|
else
|
139
|
-
#
|
140
|
-
|
141
|
-
|
140
|
+
# If there is a mask, read that next
|
141
|
+
if @masked
|
142
|
+
transition(:mask, 4)
|
143
|
+
else
|
144
|
+
# Otherwise, the payload is next.
|
145
|
+
# Keep the current payload length, a 7 bit value.
|
146
|
+
# Go to read the payload
|
147
|
+
transition(:payload, @payload_length)
|
148
|
+
end
|
142
149
|
end # case @payload_length
|
143
150
|
|
144
151
|
# This state yields no output.
|
@@ -149,7 +156,7 @@ class FTW::WebSocket::Parser
|
|
149
156
|
# This is the 'extended payload length' with support for both 16
|
150
157
|
# and 64 bit lengths.
|
151
158
|
# See: http://tools.ietf.org/html/rfc6455#section-5.2
|
152
|
-
def
|
159
|
+
def extended_payload_length
|
153
160
|
# 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
|
154
161
|
# +-+-+-+-+-------+-+-------------+-------------------------------+
|
155
162
|
# |F|R|R|R| opcode|M| Payload len | Extended payload length |
|
@@ -171,11 +178,27 @@ class FTW::WebSocket::Parser
|
|
171
178
|
raise "Unknown payload_length byte length '#{@need}'"
|
172
179
|
end
|
173
180
|
|
174
|
-
|
181
|
+
if @masked
|
182
|
+
# Read the mask next if there is one.
|
183
|
+
transition(:mask, 4)
|
184
|
+
else
|
185
|
+
# Otherwise, next is the payload
|
186
|
+
transition(:payload, @payload_length)
|
187
|
+
end
|
175
188
|
|
176
189
|
# This state yields no output.
|
177
190
|
return nil
|
178
|
-
end # def
|
191
|
+
end # def extended_payload_length
|
192
|
+
|
193
|
+
def mask
|
194
|
+
# + - - - - - - - - - - - - - - - +-------------------------------+
|
195
|
+
# | |Masking-key, if MASK set to 1 |
|
196
|
+
# +-------------------------------+-------------------------------+
|
197
|
+
# | Masking-key (continued) | Payload Data |
|
198
|
+
# +-------------------------------- - - - - - - - - - - - - - - - +
|
199
|
+
@mask = get(@need)
|
200
|
+
transition(:payload, @payload_length)
|
201
|
+
end # def mask
|
179
202
|
|
180
203
|
# State: payload
|
181
204
|
# Read the full payload and return it.
|
@@ -189,8 +212,24 @@ class FTW::WebSocket::Parser
|
|
189
212
|
# thing. Have the consumer of this library make that decision.
|
190
213
|
data = get(@need)
|
191
214
|
transition(:flags_and_opcode, 1)
|
192
|
-
|
215
|
+
if @masked
|
216
|
+
return unmask(data, @mask)
|
217
|
+
else
|
218
|
+
return data
|
219
|
+
end
|
193
220
|
end # def payload
|
194
221
|
|
222
|
+
def unmask(message, key)
|
223
|
+
masked = []
|
224
|
+
mask_bytes = key.unpack("C4")
|
225
|
+
i = 0
|
226
|
+
message.each_byte do |byte|
|
227
|
+
masked << (byte ^ mask_bytes[i % 4])
|
228
|
+
i += 1
|
229
|
+
end
|
230
|
+
p :unmasked => masked.pack("C*"), :original => message
|
231
|
+
return masked.pack("C*")
|
232
|
+
end # def mask
|
233
|
+
|
195
234
|
public(:feed)
|
196
235
|
end # class FTW::WebSocket::Parser
|
@@ -0,0 +1,77 @@
|
|
1
|
+
require "ftw/namespace"
|
2
|
+
require "ftw/websocket/parser"
|
3
|
+
require "base64" # stdlib
|
4
|
+
require "digest/sha1" # stdlib
|
5
|
+
|
6
|
+
class FTW::WebSocket::Rack
|
7
|
+
include FTW::WebSocket::Constants
|
8
|
+
|
9
|
+
private
|
10
|
+
|
11
|
+
def initialize(rack_env)
|
12
|
+
@env = rack_env
|
13
|
+
@handshake_errors = []
|
14
|
+
|
15
|
+
# RFC6455 section 4.2.1 bullet 3
|
16
|
+
expect_equal("websocket", @env["HTTP_UPGRADE"],
|
17
|
+
"The 'Upgrade' header must be set to 'websocket'")
|
18
|
+
# RFC6455 section 4.2.1 bullet 4
|
19
|
+
expect_equal("Upgrade", @env["HTTP_CONNECTION"],
|
20
|
+
"The 'Connection' header must be set to 'Upgrade'")
|
21
|
+
# RFC6455 section 4.2.1 bullet 6
|
22
|
+
expect_equal("13", @env["HTTP_SEC_WEBSOCKET_VERSION"],
|
23
|
+
"Sec-WebSocket-Version must be set to 13")
|
24
|
+
|
25
|
+
# RFC6455 section 4.2.1 bullet 5
|
26
|
+
@key = @env["HTTP_SEC_WEBSOCKET_KEY"]
|
27
|
+
|
28
|
+
@parser = FTW::WebSocket::Parser.new
|
29
|
+
end # def initialize
|
30
|
+
|
31
|
+
def expect_equal(expected, actual, message)
|
32
|
+
if expected != actual
|
33
|
+
@handshake_errors << message
|
34
|
+
end
|
35
|
+
end # def expected
|
36
|
+
|
37
|
+
def valid?
|
38
|
+
return @handshake_errors.empty?
|
39
|
+
end # def valid?
|
40
|
+
|
41
|
+
def rack_response
|
42
|
+
if valid?
|
43
|
+
# Return the status, headers, body that is expected.
|
44
|
+
sec_accept = @key + WEBSOCKET_ACCEPT_UUID
|
45
|
+
sec_accept_hash = Digest::SHA1.base64digest(sec_accept)
|
46
|
+
|
47
|
+
headers = {
|
48
|
+
"Upgrade" => "websocket",
|
49
|
+
"Connection" => "Upgrade",
|
50
|
+
"Sec-WebSocket-Accept" => sec_accept_hash
|
51
|
+
}
|
52
|
+
# See RFC6455 section 4.2.2
|
53
|
+
return 101, headers, nil
|
54
|
+
else
|
55
|
+
# Invalid request, tell the client why.
|
56
|
+
return 400, { "Content-Type" => "text/plain" },
|
57
|
+
@handshake_errors.map { |m| "#{m}#{CRLF}" }
|
58
|
+
end
|
59
|
+
end # def rack_response
|
60
|
+
|
61
|
+
def each
|
62
|
+
connection = @env["ftw.connection"]
|
63
|
+
while true
|
64
|
+
data = connection.read(16384)
|
65
|
+
@parser.feed(data) do |payload|
|
66
|
+
yield payload if !payload.nil?
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end # def each
|
70
|
+
|
71
|
+
def publish(message)
|
72
|
+
writer = FTW::WebSocket::Writer.singleton
|
73
|
+
writer.write_text(@env["ftw.connection"], message)
|
74
|
+
end # def publish
|
75
|
+
|
76
|
+
public(:initialize, :valid?, :rack_response, :each, :publish)
|
77
|
+
end # class FTW::WebSocket::Rack
|
@@ -0,0 +1,114 @@
|
|
1
|
+
require "ftw/namespace"
|
2
|
+
require "ftw/websocket"
|
3
|
+
require "ftw/singleton"
|
4
|
+
require "ftw/websocket/constants"
|
5
|
+
|
6
|
+
# This class implements a writer for WebSocket messages over a stream.
|
7
|
+
#
|
8
|
+
# Protocol diagram copied from RFC6455
|
9
|
+
# 0 1 2 3
|
10
|
+
# 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
|
11
|
+
# +-+-+-+-+-------+-+-------------+-------------------------------+
|
12
|
+
# |F|R|R|R| opcode|M| Payload len | Extended payload length |
|
13
|
+
# |I|S|S|S| (4) |A| (7) | (16/64) |
|
14
|
+
# |N|V|V|V| |S| | (if payload len==126/127) |
|
15
|
+
# | |1|2|3| |K| | |
|
16
|
+
# +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
|
17
|
+
# | Extended payload length continued, if payload len == 127 |
|
18
|
+
# + - - - - - - - - - - - - - - - +-------------------------------+
|
19
|
+
# | |Masking-key, if MASK set to 1 |
|
20
|
+
# +-------------------------------+-------------------------------+
|
21
|
+
# | Masking-key (continued) | Payload Data |
|
22
|
+
# +-------------------------------- - - - - - - - - - - - - - - - +
|
23
|
+
# : Payload Data continued ... :
|
24
|
+
# + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
|
25
|
+
# | Payload Data continued ... |
|
26
|
+
# +---------------------------------------------------------------+
|
27
|
+
|
28
|
+
class FTW::WebSocket::Writer
|
29
|
+
include FTW::WebSocket::Constants
|
30
|
+
extend FTW::Singleton
|
31
|
+
|
32
|
+
#
|
33
|
+
VALID_MODES = [:server, :client]
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
# Write the given text in a websocket frame to the connection.
|
38
|
+
#
|
39
|
+
# Valid 'mode' settings are :server or :client. If :client, the
|
40
|
+
# payload will be masked according to RFC6455 section 5.3:
|
41
|
+
# http://tools.ietf.org/html/rfc6455#section-5.3
|
42
|
+
def write_text(connection, text, mode=:server)
|
43
|
+
if !VALID_MODES.include?(mode)
|
44
|
+
raise InvalidArgument.new("Invalid message mode: #{mode}, expected one of" \
|
45
|
+
"#{VALID_MODES.inspect}")
|
46
|
+
end
|
47
|
+
|
48
|
+
data = []
|
49
|
+
pack = []
|
50
|
+
|
51
|
+
# For now, assume single-fragment, text frames
|
52
|
+
pack_opcode(data, pack, OPCODE_TEXT)
|
53
|
+
pack_payload(data, pack, text, mode)
|
54
|
+
connection.write(data.pack(pack.join("")))
|
55
|
+
end # def write_text
|
56
|
+
|
57
|
+
def pack_opcode(data, pack, opcode)
|
58
|
+
# Pack the first byte (fin + opcode)
|
59
|
+
data << ((1 << 7) | opcode)
|
60
|
+
pack << "C"
|
61
|
+
end # def pack_opcode
|
62
|
+
|
63
|
+
def pack_payload(data, pack, text, mode)
|
64
|
+
pack_maskbit_and_length(data, pack, text.length, mode)
|
65
|
+
pack_extended_length(data, pack, text.length) if text.length > 126
|
66
|
+
if mode == :client
|
67
|
+
mask_key = [rand(1 << 32)].pack("Q")
|
68
|
+
pack_mask(data, pack, mask_key)
|
69
|
+
data << mask(text, mask_key)
|
70
|
+
pack << "A*"
|
71
|
+
else
|
72
|
+
data << text
|
73
|
+
pack << "A*"
|
74
|
+
end
|
75
|
+
end # def pack_payload
|
76
|
+
|
77
|
+
# Implement masking as described by http://tools.ietf.org/html/rfc6455#section-5.3
|
78
|
+
# Basically, we take a 4-byte random string and use it, round robin, to XOR
|
79
|
+
# every byte. Like so:
|
80
|
+
# message[0] ^ key[0]
|
81
|
+
# message[1] ^ key[1]
|
82
|
+
# message[2] ^ key[2]
|
83
|
+
# message[3] ^ key[3]
|
84
|
+
# message[4] ^ key[0]
|
85
|
+
# ...
|
86
|
+
def mask(message, key)
|
87
|
+
masked = []
|
88
|
+
mask_bytes = key.unpack("C4")
|
89
|
+
i = 0
|
90
|
+
message.each_byte do |byte|
|
91
|
+
masked << (byte ^ mask_bytes[i % 4])
|
92
|
+
i += 1
|
93
|
+
end
|
94
|
+
return masked.pack("C*")
|
95
|
+
end # def mask
|
96
|
+
|
97
|
+
def pack_maskbit_and_length(data, pack, length, mode)
|
98
|
+
# Pack mask + payload length
|
99
|
+
maskbit = (mode == :client) ? (1 << 7) : 0
|
100
|
+
if length >= 126
|
101
|
+
if length < (1 << 16) # if less than 2^16, use 2 bytes
|
102
|
+
lengthbits = 126
|
103
|
+
else
|
104
|
+
lengthbits = 127
|
105
|
+
end
|
106
|
+
else
|
107
|
+
lengthbits = length
|
108
|
+
end
|
109
|
+
data << (maskbit | lengthbits)
|
110
|
+
pack << "C"
|
111
|
+
end
|
112
|
+
|
113
|
+
public(:initialize, :write_text)
|
114
|
+
end # module FTW::WebSocket::Writer
|
@@ -0,0 +1,153 @@
|
|
1
|
+
require "rack"
|
2
|
+
require "ftw"
|
3
|
+
require "ftw/protocol"
|
4
|
+
require "ftw/crlf"
|
5
|
+
require "socket"
|
6
|
+
|
7
|
+
# FTW cannot fully respect the Rack 1.1 specification due to technical
|
8
|
+
# limitations in the Rack design, specifically:
|
9
|
+
#
|
10
|
+
# * rack.input must be buffered, to support IO#rewind, for the duration of each
|
11
|
+
# request. This is not safe if that request is an HTTP Upgrade or a long
|
12
|
+
# upload.
|
13
|
+
#
|
14
|
+
# FTW::Connection does not implement #rewind. Need it? File a ticket.
|
15
|
+
#
|
16
|
+
# To support HTTP Upgrade, CONNECT, and protocol-switching features, this
|
17
|
+
# server handler will set "ftw.connection" to the FTW::Connection related
|
18
|
+
# to this request.
|
19
|
+
#
|
20
|
+
# The above data is based on the response to this ticket:
|
21
|
+
# https://github.com/rack/rack/issues/347
|
22
|
+
class Rack::Handler::FTW
|
23
|
+
include FTW::Protocol
|
24
|
+
include FTW::CRLF
|
25
|
+
|
26
|
+
RACK_VERSION = [1,1]
|
27
|
+
REQUEST_METHOD = "REQUEST_METHOD".freeze
|
28
|
+
SCRIPT_NAME = "SCRIPT_NAME".freeze
|
29
|
+
PATH_INFO = "PATH_INFO".freeze
|
30
|
+
QUERY_STRING = "QUERY_STRING".freeze
|
31
|
+
SERVER_NAME = "SERVER_NAME".freeze
|
32
|
+
SERVER_PORT = "SERVER_PORT".freeze
|
33
|
+
|
34
|
+
RACK_DOT_VERSION = "rack.version".freeze
|
35
|
+
RACK_DOT_URL_SCHEME = "rack.url_scheme".freeze
|
36
|
+
RACK_DOT_INPUT = "rack.input".freeze
|
37
|
+
RACK_DOT_ERRORS = "rack.errors".freeze
|
38
|
+
RACK_DOT_MULTITHREAD = "rack.multithread".freeze
|
39
|
+
RACK_DOT_MULTIPROCESS = "rack.multiprocess".freeze
|
40
|
+
RACK_DOT_RUN_ONCE = "rack.run_once".freeze
|
41
|
+
FTW_DOT_CONNECTION = "ftw.connection".freeze
|
42
|
+
|
43
|
+
def self.run(app, config)
|
44
|
+
server = self.new(app, config)
|
45
|
+
server.run
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def initialize(app, config)
|
51
|
+
@app = app
|
52
|
+
@config = config
|
53
|
+
end
|
54
|
+
|
55
|
+
def run
|
56
|
+
# {:environment=>"development", :pid=>nil, :Port=>9292, :Host=>"0.0.0.0",
|
57
|
+
# :AccessLog=>[], :config=>"/home/jls/projects/ruby-ftw/examples/test.ru",
|
58
|
+
# :server=>"FTW"}
|
59
|
+
#
|
60
|
+
# listen, pass connections off
|
61
|
+
#
|
62
|
+
#
|
63
|
+
# """A Rack application is an Ruby object (not a class) that responds to
|
64
|
+
# call. It takes exactly one argument, the environment and returns an
|
65
|
+
# Array of exactly three values: The status, the headers, and the body."""
|
66
|
+
#
|
67
|
+
server = FTW::Server.new([@config[:Host], @config[:Port]].join(":"))
|
68
|
+
server.each_connection do |connection|
|
69
|
+
Thread.new do
|
70
|
+
handle_connection(connection)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end # def run
|
74
|
+
|
75
|
+
def handle_connection(connection)
|
76
|
+
while true
|
77
|
+
begin
|
78
|
+
request = read_http_message(connection)
|
79
|
+
handle_request(request, connection)
|
80
|
+
rescue => e
|
81
|
+
puts e.inspect
|
82
|
+
puts e.backtrace
|
83
|
+
raise e
|
84
|
+
end
|
85
|
+
end
|
86
|
+
connection.disconnect("Fun")
|
87
|
+
end # def handle_connection
|
88
|
+
|
89
|
+
def handle_request(request, connection)
|
90
|
+
path, query = request.path.split("?", 2)
|
91
|
+
env = {
|
92
|
+
# CGI-like environment as required by the Rack SPEC version 1.1
|
93
|
+
REQUEST_METHOD => request.method,
|
94
|
+
SCRIPT_NAME => "/", # TODO(sissel): not totally sure what this really should be
|
95
|
+
PATH_INFO => path,
|
96
|
+
QUERY_STRING => query.nil? ? "" : query,
|
97
|
+
SERVER_NAME => "hahaha, no", # TODO(sissel): Set this
|
98
|
+
SERVER_PORT => "", # TODO(sissel): Set this
|
99
|
+
|
100
|
+
# Rack-specific environment, also required by Rack SPEC version 1.1
|
101
|
+
RACK_DOT_VERSION => RACK_VERSION,
|
102
|
+
RACK_DOT_URL_SCHEME => "http", # TODO(sissel): support https
|
103
|
+
RACK_DOT_INPUT => connection,
|
104
|
+
RACK_DOT_ERRORS => STDERR,
|
105
|
+
RACK_DOT_MULTITHREAD => true,
|
106
|
+
RACK_DOT_MULTIPROCESS => false,
|
107
|
+
RACK_DOT_RUN_ONCE => false,
|
108
|
+
|
109
|
+
# Extensions, not in Rack v1.1.
|
110
|
+
|
111
|
+
# ftw.connection lets you access the connection involved in this request.
|
112
|
+
# It should be used when you need to hijack the connection for use
|
113
|
+
# in proxying, HTTP CONNECT, websockets, SPDY(maybe?), etc.
|
114
|
+
FTW_DOT_CONNECTION => connection
|
115
|
+
}
|
116
|
+
|
117
|
+
request.headers.each do |name, value|
|
118
|
+
# The Rack spec says:
|
119
|
+
# """ Variables corresponding to the client-supplied HTTP request headers
|
120
|
+
# (i.e., variables whose names begin with HTTP_). The presence or
|
121
|
+
# absence of these variables should correspond with the presence or
|
122
|
+
# absence of the appropriate HTTP header in the request. """
|
123
|
+
#
|
124
|
+
# It doesn't specify how to translate the header names into this hash syntax.
|
125
|
+
# I looked at what Thin does, and it capitalizes and replaces dashes with
|
126
|
+
# underscores, so I'll just copy that behavior. The specific code that implements
|
127
|
+
# this in thin is here:
|
128
|
+
# https://github.com/macournoyer/thin/blob/2e9db13e414ae7425/ext/thin_parser/thin.c#L89-L95
|
129
|
+
#
|
130
|
+
# The Rack spec also doesn't describe what should be done for headers
|
131
|
+
# with multiple values.
|
132
|
+
#
|
133
|
+
env["HTTP_#{name.upcase.gsub("-", "_")}"] = value
|
134
|
+
end # request.headers.each
|
135
|
+
|
136
|
+
status, headers, body = @app.call(env)
|
137
|
+
|
138
|
+
response = FTW::Response.new
|
139
|
+
response.status = status.to_i
|
140
|
+
response.version = request.version
|
141
|
+
headers.each do |name, value|
|
142
|
+
response.headers.add(name, value)
|
143
|
+
end
|
144
|
+
response.body = body
|
145
|
+
|
146
|
+
connection.write(response.to_s + CRLF)
|
147
|
+
body.each do |chunk|
|
148
|
+
connection.write(chunk)
|
149
|
+
end
|
150
|
+
end # def handle_request
|
151
|
+
|
152
|
+
public(:run, :initialize)
|
153
|
+
end
|