http_tools 0.1.0 → 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- data/README.rdoc +11 -5
- data/bench/parser/request_bench.rb +11 -15
- data/bench/parser/response_bench.rb +12 -0
- data/bench/transfer_encoding_chunked_bench.rb +26 -0
- data/example/http_client.rb +81 -32
- data/example/http_server.rb +82 -0
- data/lib/http_tools.rb +4 -0
- data/lib/http_tools/encoding.rb +25 -24
- data/lib/http_tools/errors.rb +1 -0
- data/lib/http_tools/parser.rb +170 -157
- data/test/encoding/transfer_encoding_chunked_test.rb +22 -0
- data/test/parser/request_test.rb +169 -126
- data/test/parser/response_test.rb +520 -52
- metadata +7 -5
data/README.rdoc
CHANGED
@@ -6,20 +6,26 @@ including a fast-as-possible pure Ruby HTTP parser.
|
|
6
6
|
* rdoc[http://sourcetagsandcodes.com/http_tools/doc/]
|
7
7
|
* source[https://github.com/matsadler/http_tools]
|
8
8
|
|
9
|
+
== Platform Support
|
10
|
+
|
11
|
+
Written purely in Ruby, with no dependencies outside of the standard library, it
|
12
|
+
should run across all Ruby implementations compatible with 1.8 or later, and
|
13
|
+
install in environments without a compiler available.
|
14
|
+
|
9
15
|
== HTTPTools::Parser
|
10
16
|
|
11
17
|
HTTPTools::Parser is a HTTP request & response parser with an evented API.
|
12
|
-
Written purely in Ruby, with no dependencies, it should run across all Ruby
|
13
|
-
implementations, and install in environments without a compiler available.
|
14
18
|
Despite being just Ruby, every effort has been made to ensure it is as fast as
|
15
19
|
possible.
|
16
20
|
|
17
21
|
=== Example
|
18
22
|
|
19
23
|
parser = HTTPTools::Parser.new
|
20
|
-
parser.on(:
|
21
|
-
|
22
|
-
|
24
|
+
parser.on(:header) do |header|
|
25
|
+
puts parser.status_code + " " + parser.method
|
26
|
+
puts parser.header.inspect
|
27
|
+
end
|
28
|
+
parser.on(:stream) {|chunk| print chunk}
|
23
29
|
|
24
30
|
parser << "HTTP/1.1 200 OK\r\n"
|
25
31
|
parser << "Content-Length: 20\r\n\r\n"
|
@@ -21,29 +21,25 @@ Benchmark.bm(41) do |x|
|
|
21
21
|
|
22
22
|
x.report("HTTPTools::Parser (reset, with callbacks)") do
|
23
23
|
parser = HTTPTools::Parser.new
|
24
|
-
parser.on(:
|
25
|
-
parser.on(:path) {|arg, arg2|}
|
26
|
-
parser.on(:headers) {|arg|}
|
24
|
+
parser.on(:headers) {}
|
27
25
|
10_000.times do
|
28
26
|
parser << request
|
29
27
|
parser.reset
|
30
28
|
end
|
31
29
|
end
|
32
30
|
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
31
|
+
begin
|
32
|
+
require 'rubygems'
|
33
|
+
require 'http/parser'
|
34
|
+
x.report("Http::Parser") do
|
35
|
+
10_000.times do
|
36
|
+
parser = Http::Parser.new
|
37
|
+
parser.on_headers_complete = Proc.new {}
|
38
|
+
parser.on_message_complete = Proc.new {}
|
39
|
+
parser << request
|
40
40
|
end
|
41
41
|
end
|
42
|
-
|
43
|
-
10_000.times do
|
44
|
-
parser << request
|
45
|
-
parser.reset
|
46
|
-
end
|
42
|
+
rescue LoadError
|
47
43
|
end
|
48
44
|
|
49
45
|
begin
|
@@ -18,4 +18,16 @@ Benchmark.bm(25) do |x|
|
|
18
18
|
parser.reset
|
19
19
|
end
|
20
20
|
end
|
21
|
+
|
22
|
+
begin
|
23
|
+
require 'rubygems'
|
24
|
+
require 'http/parser'
|
25
|
+
x.report("Http::Parser") do
|
26
|
+
10_000.times do
|
27
|
+
parser = Http::Parser.new
|
28
|
+
parser << response
|
29
|
+
end
|
30
|
+
end
|
31
|
+
rescue LoadError
|
32
|
+
end
|
21
33
|
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
base = File.expand_path(File.dirname(__FILE__) + '/../lib')
|
2
|
+
require base + '/http_tools'
|
3
|
+
require 'benchmark'
|
4
|
+
|
5
|
+
Benchmark.bm(36) do |x|
|
6
|
+
x.report("lots of very short chunks") do
|
7
|
+
encoded = "1\r\na\r\n" * 100 + "0\r\n"
|
8
|
+
1_000.times do
|
9
|
+
HTTPTools::Encoding.transfer_encoding_chunked_decode(encoded)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
x.report("slightly less slightly longer chunks") do
|
14
|
+
encoded = "16\r\n<h1>Hello world</h1>\r\n\r\n12\r\n<p>Lorem ipsum</p>\r\n" * 50 + "0\r\n"
|
15
|
+
1_000.times do
|
16
|
+
HTTPTools::Encoding.transfer_encoding_chunked_decode(encoded)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
x.report("a couple of big chunks") do
|
21
|
+
encoded = "2710\r\n#{"a" * 10000}\r\n" * 2 + "0\r\n"
|
22
|
+
1_000.times do
|
23
|
+
HTTPTools::Encoding.transfer_encoding_chunked_decode(encoded)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
data/example/http_client.rb
CHANGED
@@ -29,9 +29,12 @@ module HTTP
|
|
29
29
|
CONTENT_LENGTH = "Content-Length".freeze
|
30
30
|
WWW_FORM = "application/x-www-form-urlencoded".freeze
|
31
31
|
|
32
|
+
attr_writer :keepalive
|
33
|
+
|
32
34
|
def initialize(host, port=80)
|
33
35
|
@host = host
|
34
36
|
@port = port
|
37
|
+
@pipeline = []
|
35
38
|
end
|
36
39
|
|
37
40
|
def socket
|
@@ -58,9 +61,9 @@ module HTTP
|
|
58
61
|
if headers[CONTENT_LENGTH]
|
59
62
|
# ok
|
60
63
|
elsif body.respond_to?(:length)
|
61
|
-
headers[CONTENT_LENGTH]
|
64
|
+
headers[CONTENT_LENGTH] = body.length
|
62
65
|
elsif body.respond_to?(:stat)
|
63
|
-
headers[CONTENT_LENGTH]
|
66
|
+
headers[CONTENT_LENGTH] = body.stat.size
|
64
67
|
else
|
65
68
|
raise "Content-Length must be supplied"
|
66
69
|
end
|
@@ -68,47 +71,88 @@ module HTTP
|
|
68
71
|
request(:post, path, body, headers, &block)
|
69
72
|
end
|
70
73
|
|
74
|
+
def pipeline
|
75
|
+
@pipelining = true
|
76
|
+
yield self
|
77
|
+
pipeline_requests(@pipeline)
|
78
|
+
ensure
|
79
|
+
@pipelining = false
|
80
|
+
end
|
81
|
+
|
82
|
+
def keepalive?
|
83
|
+
@keepalive
|
84
|
+
end
|
85
|
+
|
86
|
+
def keepalive
|
87
|
+
self.keepalive, original = true, keepalive?
|
88
|
+
yield self
|
89
|
+
ensure
|
90
|
+
self.keepalive = original
|
91
|
+
end
|
92
|
+
|
71
93
|
private
|
72
|
-
def request(method, path,
|
94
|
+
def request(method, path, body=nil, headers={}, response_has_body=true, &b)
|
95
|
+
request = {
|
96
|
+
:method => method,
|
97
|
+
:path => path,
|
98
|
+
:body => body,
|
99
|
+
:headers => headers,
|
100
|
+
:response_has_body => response_has_body,
|
101
|
+
:block => b}
|
102
|
+
if @pipelining
|
103
|
+
@pipeline << request
|
104
|
+
nil
|
105
|
+
else
|
106
|
+
pipeline_requests([request]).first
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
def pipeline_requests(requests)
|
73
111
|
parser = HTTPTools::Parser.new
|
74
|
-
parser.
|
75
|
-
|
112
|
+
parser.allow_html_without_header = true
|
113
|
+
responses = []
|
76
114
|
|
77
|
-
parser.
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
block.call(response)
|
83
|
-
response.parser = nil
|
115
|
+
parser.on(:finish) do |remainder|
|
116
|
+
if responses.length < requests.length
|
117
|
+
parser.reset
|
118
|
+
parser << remainder.lstrip if remainder
|
119
|
+
throw :reset
|
84
120
|
end
|
85
121
|
end
|
86
|
-
parser.
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
122
|
+
parser.on(:header) do
|
123
|
+
request = requests[responses.length]
|
124
|
+
parser.force_no_body = !request[:response_has_body]
|
125
|
+
response = Response.new(parser.status_code, parser.message)
|
126
|
+
response.headers = parser.header
|
127
|
+
parser.on(:stream) {|chunk| response.receive_chunk(chunk)}
|
128
|
+
responses.push(response)
|
91
129
|
end
|
92
130
|
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
rescue EOFError
|
98
|
-
parser.finish
|
99
|
-
break
|
131
|
+
requests.each do |r|
|
132
|
+
socket << HTTPTools::Builder.request(r[:method], @host, r[:path], r[:headers])
|
133
|
+
if body = r[:body]
|
134
|
+
socket << body.read(1024 * 16) until body.eof?
|
100
135
|
end
|
101
136
|
end
|
102
|
-
|
137
|
+
|
138
|
+
begin
|
139
|
+
catch(:reset) {parser << socket.sysread(1024 * 16)}
|
140
|
+
rescue EOFError
|
141
|
+
@socket = nil
|
142
|
+
parser.finish
|
143
|
+
break
|
144
|
+
end until parser.finished?
|
145
|
+
|
146
|
+
@socket = nil unless keepalive?
|
147
|
+
responses
|
103
148
|
end
|
104
149
|
end
|
105
150
|
|
106
151
|
class Response
|
107
152
|
attr_reader :status, :message
|
108
153
|
attr_accessor :headers, :body
|
109
|
-
attr_accessor :parser # :nodoc:
|
110
154
|
|
111
|
-
def initialize(status, message, headers={}, body=
|
155
|
+
def initialize(status, message, headers={}, body="")
|
112
156
|
@status = status
|
113
157
|
@message = message
|
114
158
|
@headers = headers
|
@@ -116,17 +160,22 @@ module HTTP
|
|
116
160
|
end
|
117
161
|
|
118
162
|
def stream(&block)
|
119
|
-
|
120
|
-
parser.add_listener(:stream, block)
|
121
|
-
else
|
122
|
-
block.call(body)
|
123
|
-
end
|
163
|
+
@stream_callback = block
|
124
164
|
nil
|
125
165
|
end
|
126
166
|
|
167
|
+
def receive_chunk(chunk) # :nodoc:
|
168
|
+
body << chunk
|
169
|
+
@stream_callback.call(chunk) if @stream_callback
|
170
|
+
end
|
171
|
+
|
127
172
|
def inspect
|
128
173
|
bytesize = body.respond_to?(:bytesize) ? body.bytesize : body.to_s.length
|
129
174
|
"#<Response #{status} #{message}: #{bytesize} bytes>"
|
130
175
|
end
|
176
|
+
|
177
|
+
def to_s
|
178
|
+
body.to_s
|
179
|
+
end
|
131
180
|
end
|
132
181
|
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
require 'socket'
|
2
|
+
require 'stringio'
|
3
|
+
require 'rubygems'
|
4
|
+
require 'http_tools'
|
5
|
+
|
6
|
+
module HTTP
|
7
|
+
class Server
|
8
|
+
RACK_INPUT = "rack.input".freeze
|
9
|
+
NO_BODY = {"GET" => true, "HEAD" => true}
|
10
|
+
CONNECTION = "Connection".freeze
|
11
|
+
KEEP_ALIVE = "Keep-Alive".freeze
|
12
|
+
CLOSE = "close".freeze
|
13
|
+
ONE_ONE = "1.1".freeze
|
14
|
+
|
15
|
+
def initialize(app, options={})
|
16
|
+
host = options[:host] || options[:Host] || "0.0.0.0"
|
17
|
+
port = (options[:port] || options[:Port] || 9292).to_s
|
18
|
+
@app = app
|
19
|
+
@instance_env = {"SERVER_NAME" => host, "SERVER_PORT" => port,
|
20
|
+
"rack.multithread" => true}
|
21
|
+
@server = TCPServer.new(host, port)
|
22
|
+
@server.listen(1024)
|
23
|
+
end
|
24
|
+
|
25
|
+
def self.run(app, options={})
|
26
|
+
new(app, options).listen
|
27
|
+
end
|
28
|
+
|
29
|
+
def listen
|
30
|
+
while socket = @server.accept
|
31
|
+
Thread.new do
|
32
|
+
begin
|
33
|
+
on_connection(socket)
|
34
|
+
rescue StandardError, LoadError, SyntaxError => e
|
35
|
+
STDERR.puts("#{e.class}: #{e.message} #{e.backtrace.join("\n")}")
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
def on_connection(socket)
|
43
|
+
parser = HTTPTools::Parser.new
|
44
|
+
env, input = nil
|
45
|
+
|
46
|
+
parser.on(:header) do
|
47
|
+
parser.force_no_body = NO_BODY[parser.request_method]
|
48
|
+
input = StringIO.new
|
49
|
+
env = parser.env.merge!(RACK_INPUT => input).merge!(@instance_env)
|
50
|
+
end
|
51
|
+
parser.on(:stream) {|chunk| input << chunk}
|
52
|
+
parser.on(:finish) do |remainder|
|
53
|
+
input.rewind
|
54
|
+
status, header, body = @app.call(env)
|
55
|
+
keep_alive = keep_alive?(parser.version, parser.header[CONNECTION])
|
56
|
+
header[CONNECTION] = keep_alive ? KEEP_ALIVE : CLOSE
|
57
|
+
socket << HTTPTools::Builder.response(status, header)
|
58
|
+
body.each {|chunk| socket << chunk}
|
59
|
+
body.close if body.respond_to?(:close)
|
60
|
+
if keep_alive
|
61
|
+
parser.reset
|
62
|
+
parser << remainder.lstrip if remainder
|
63
|
+
throw :reset
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
begin
|
68
|
+
readable, = select([socket], nil, nil, 30)
|
69
|
+
break unless readable
|
70
|
+
catch(:reset) {parser << socket.read_nonblock(1024 * 16)}
|
71
|
+
rescue EOFError
|
72
|
+
break
|
73
|
+
end until parser.finished?
|
74
|
+
socket.close
|
75
|
+
end
|
76
|
+
|
77
|
+
def keep_alive?(http_version, connection)
|
78
|
+
http_version == ONE_ONE && connection != CLOSE || connection == KEEP_ALIVE
|
79
|
+
end
|
80
|
+
|
81
|
+
end
|
82
|
+
end
|
data/lib/http_tools.rb
CHANGED
@@ -96,6 +96,9 @@ module HTTPTools
|
|
96
96
|
NO_BODY.merge!(204 => true, 304 => true, nil => false)
|
97
97
|
100.upto(199) {|status_code| NO_BODY[status_code] = true}
|
98
98
|
|
99
|
+
ARRAY_VALUE_HEADERS = Hash.new {|hash, key| hash[key] = false}
|
100
|
+
ARRAY_VALUE_HEADERS.merge!("Set-Cookie" => true)
|
101
|
+
|
99
102
|
CRLF = "\r\n".freeze
|
100
103
|
SPACE = " ".freeze
|
101
104
|
|
@@ -106,5 +109,6 @@ module HTTPTools
|
|
106
109
|
autoload :ParseError, require_base + 'errors'
|
107
110
|
autoload :EndOfMessageError, require_base + 'errors'
|
108
111
|
autoload :MessageIncompleteError, require_base + 'errors'
|
112
|
+
autoload :EmptyMessageError, require_base + 'errors'
|
109
113
|
|
110
114
|
end
|
data/lib/http_tools/encoding.rb
CHANGED
@@ -134,35 +134,36 @@ module HTTPTools
|
|
134
134
|
# while remainder
|
135
135
|
# remainder << get_data
|
136
136
|
# chunk, remainder = transfer_encoding_chunked_decode(remainder)
|
137
|
-
# decoded << chunk
|
137
|
+
# decoded << chunk if chunk
|
138
138
|
# end
|
139
139
|
#
|
140
|
-
def transfer_encoding_chunked_decode(scanner)
|
141
|
-
|
142
|
-
scanner = StringScanner.new(scanner.dup)
|
143
|
-
end
|
144
|
-
hex_chunk_length = scanner.scan(/[0-9a-fA-F]+\r?\n/)
|
145
|
-
return [nil, scanner.string] unless hex_chunk_length
|
146
|
-
|
147
|
-
chunk_length = hex_chunk_length.to_i(16)
|
148
|
-
return [nil, nil] if chunk_length == 0
|
140
|
+
def transfer_encoding_chunked_decode(str, scanner=StringScanner.new(str))
|
141
|
+
decoded = ""
|
149
142
|
|
150
|
-
|
151
|
-
|
152
|
-
scanner.
|
153
|
-
|
154
|
-
|
143
|
+
remainder = while true
|
144
|
+
start_pos = scanner.pos
|
145
|
+
hex_chunk_length = scanner.scan(/[0-9a-f]+ *\r?\n/i)
|
146
|
+
break scanner.rest unless hex_chunk_length
|
147
|
+
|
148
|
+
chunk_length = hex_chunk_length.to_i(16)
|
149
|
+
break nil if chunk_length == 0
|
150
|
+
|
151
|
+
begin
|
152
|
+
chunk = scanner.rest.slice(0, chunk_length)
|
153
|
+
scanner.pos += chunk_length
|
154
|
+
if chunk && scanner.skip(/\r?\n/i)
|
155
|
+
decoded << chunk
|
156
|
+
else
|
157
|
+
scanner.pos = start_pos
|
158
|
+
break scanner.rest
|
159
|
+
end
|
160
|
+
rescue RangeError
|
161
|
+
scanner.pos = start_pos
|
162
|
+
break scanner.rest
|
163
|
+
end
|
155
164
|
end
|
156
165
|
|
157
|
-
if
|
158
|
-
scanner.string.replace(scanner.rest)
|
159
|
-
scanner.reset
|
160
|
-
rest, remainder = transfer_encoding_chunked_decode(scanner)
|
161
|
-
chunk << rest if rest
|
162
|
-
[chunk, remainder]
|
163
|
-
else
|
164
|
-
[nil, scanner.string]
|
165
|
-
end
|
166
|
+
[(decoded if decoded.length > 0), remainder]
|
166
167
|
end
|
167
168
|
|
168
169
|
end
|