http_tools 0.3.0 → 0.4.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.
- data/README.rdoc +3 -3
- data/bench/parser/large_response_bench.rb +40 -0
- data/bench/transfer_encoding_chunked_bench.rb +4 -4
- data/example/http_client.rb +24 -139
- data/example/http_server.rb +1 -14
- data/lib/http_tools.rb +7 -5
- data/lib/http_tools/builder.rb +23 -11
- data/lib/http_tools/encoding.rb +4 -2
- data/lib/http_tools/parser.rb +136 -57
- data/profile/parser/large_response_profile.rb +16 -0
- data/test/builder/request_test.rb +21 -3
- data/test/builder/response_test.rb +14 -1
- data/test/cover.rb +3 -3
- data/test/parser/request_test.rb +32 -6
- data/test/parser/response_test.rb +123 -5
- metadata +6 -6
- data/example/simple_http_client.rb +0 -67
- data/lib/http_tools/errors.rb +0 -6
data/lib/http_tools/parser.rb
CHANGED
@@ -1,4 +1,5 @@
|
|
1
1
|
require 'strscan'
|
2
|
+
require 'stringio'
|
2
3
|
|
3
4
|
module HTTPTools
|
4
5
|
|
@@ -15,7 +16,7 @@ module HTTPTools
|
|
15
16
|
# puts parser.status_code + " " + parser.request_method
|
16
17
|
# puts parser.header.inspect
|
17
18
|
# end
|
18
|
-
# parser.on(:
|
19
|
+
# parser.on(:finish) {print parser.body}
|
19
20
|
#
|
20
21
|
# parser << "HTTP/1.1 200 OK\r\n"
|
21
22
|
# parser << "Content-Length: 20\r\n\r\n"
|
@@ -27,8 +28,7 @@ module HTTPTools
|
|
27
28
|
# <h1>Hello world</h1>
|
28
29
|
#
|
29
30
|
class Parser
|
30
|
-
|
31
|
-
|
31
|
+
# :stopdoc:
|
32
32
|
COLON = ":".freeze
|
33
33
|
KEY_TERMINATOR = ": ".freeze
|
34
34
|
CONTENT_LENGTH = "Content-Length".freeze
|
@@ -37,7 +37,6 @@ module HTTPTools
|
|
37
37
|
CONNECTION = "Connection".freeze
|
38
38
|
CLOSE = "close".freeze
|
39
39
|
CHUNKED = "chunked".freeze
|
40
|
-
EVENTS = %W{header stream trailer finish error}.map {|e| e.freeze}.freeze
|
41
40
|
|
42
41
|
REQUEST_METHOD = "REQUEST_METHOD".freeze
|
43
42
|
PATH_INFO = "PATH_INFO".freeze
|
@@ -45,6 +44,7 @@ module HTTPTools
|
|
45
44
|
SERVER_NAME = "SERVER_NAME".freeze
|
46
45
|
SERVER_PORT = "SERVER_PORT".freeze
|
47
46
|
HTTP_HOST = "HTTP_HOST".freeze
|
47
|
+
RACK_INPUT = "rack.input".freeze
|
48
48
|
|
49
49
|
PROTOTYPE_ENV = {
|
50
50
|
"SCRIPT_NAME" => "".freeze,
|
@@ -59,10 +59,12 @@ module HTTPTools
|
|
59
59
|
LOWERCASE = "a-z-".freeze
|
60
60
|
UPPERCASE = "A-Z_".freeze
|
61
61
|
NO_HTTP_ = {"CONTENT_LENGTH" => true, "CONTENT_TYPE" => true}
|
62
|
+
# :startdoc:
|
63
|
+
EVENTS = %W{header stream trailer finish error}.map {|e| e.freeze}.freeze
|
62
64
|
|
63
65
|
attr_reader :state # :nodoc:
|
64
66
|
attr_reader :request_method, :path_info, :query_string, :request_uri,
|
65
|
-
:version, :status_code, :message, :header, :trailer
|
67
|
+
:version, :status_code, :message, :header, :body, :trailer
|
66
68
|
|
67
69
|
# Force parser to expect and parse a trailer when Trailer header missing.
|
68
70
|
attr_accessor :force_trailer
|
@@ -79,9 +81,21 @@ module HTTPTools
|
|
79
81
|
#
|
80
82
|
def initialize
|
81
83
|
@state = :start
|
82
|
-
@buffer = StringScanner.new("")
|
84
|
+
@buffer = @scanner = StringScanner.new("")
|
83
85
|
@header = {}
|
84
86
|
@trailer = {}
|
87
|
+
@force_no_body = nil
|
88
|
+
@allow_html_without_header = nil
|
89
|
+
@force_trailer = nil
|
90
|
+
@status_code = nil
|
91
|
+
@content_left = nil
|
92
|
+
@chunked = nil
|
93
|
+
@body = nil
|
94
|
+
@header_callback = nil
|
95
|
+
@stream_callback = method(:setup_stream_callback)
|
96
|
+
@trailer_callback = nil
|
97
|
+
@finish_callback = nil
|
98
|
+
@error_callback = nil
|
85
99
|
end
|
86
100
|
|
87
101
|
# :call-seq: parser.concat(data) -> parser
|
@@ -105,8 +119,9 @@ module HTTPTools
|
|
105
119
|
# Returns a Rack compatible environment hash. Will return nil if called
|
106
120
|
# before headers are complete.
|
107
121
|
#
|
108
|
-
# "rack.input" is
|
109
|
-
#
|
122
|
+
# "rack.input" is only supplied if #env is called after parsing the request
|
123
|
+
# has finsished, and no listener is set for the `stream` event, otherwise
|
124
|
+
# you must add it yourself to make the environment hash fully Rack compliant
|
110
125
|
#
|
111
126
|
def env
|
112
127
|
return unless @header_complete
|
@@ -123,6 +138,9 @@ module HTTPTools
|
|
123
138
|
env[SERVER_NAME] = host
|
124
139
|
env[SERVER_PORT] = port || "80"
|
125
140
|
@trailer.each {|k, val| env[HTTP_ + k.tr(LOWERCASE, UPPERCASE)] = val}
|
141
|
+
if @body || @stream_callback == method(:setup_stream_callback)
|
142
|
+
env[RACK_INPUT] = StringIO.new(@body || "")
|
143
|
+
end
|
126
144
|
env
|
127
145
|
end
|
128
146
|
|
@@ -151,9 +169,10 @@ module HTTPTools
|
|
151
169
|
#
|
152
170
|
def finish
|
153
171
|
if @state == :body_on_close
|
172
|
+
@buffer = @scanner
|
154
173
|
@state = end_of_message
|
155
|
-
elsif @state == :body_chunked && @
|
156
|
-
|
174
|
+
elsif @state == :body_chunked && @buffer.eos? && !@trailer_expected &&
|
175
|
+
@header.any? {|k,v| CONNECTION.casecmp(k) == 0 && CLOSE.casecmp(v) == 0}
|
157
176
|
@state = end_of_message
|
158
177
|
elsif @state == :start && @buffer.string.length < 1
|
159
178
|
raise EmptyMessageError.new("Message empty")
|
@@ -204,6 +223,8 @@ module HTTPTools
|
|
204
223
|
@trailer = {}
|
205
224
|
@last_key = nil
|
206
225
|
@content_left = nil
|
226
|
+
@chunked = nil
|
227
|
+
@trailer_expected = nil
|
207
228
|
self
|
208
229
|
end
|
209
230
|
|
@@ -214,14 +235,15 @@ module HTTPTools
|
|
214
235
|
#
|
215
236
|
# Available events are :header, :stream, :trailer, :finish, and :error.
|
216
237
|
#
|
217
|
-
# Adding a second callback for an event will overwite the existing callback
|
218
|
-
# or delegate.
|
238
|
+
# Adding a second callback for an event will overwite the existing callback.
|
219
239
|
#
|
220
240
|
# Events:
|
221
241
|
# [header] Called when headers are complete
|
222
242
|
#
|
223
243
|
# [stream] Supplied with one argument, the last chunk of body data fed
|
224
|
-
# in to the parser as a String, e.g. "<h1>Hello"
|
244
|
+
# in to the parser as a String, e.g. "<h1>Hello". If no
|
245
|
+
# listener is set for this event the body can be retrieved with
|
246
|
+
# #body
|
225
247
|
#
|
226
248
|
# [trailer] Called on the completion of the trailer, if present
|
227
249
|
#
|
@@ -240,6 +262,12 @@ module HTTPTools
|
|
240
262
|
end
|
241
263
|
alias on add_listener
|
242
264
|
|
265
|
+
def inspect # :nodoc:
|
266
|
+
str = to_s
|
267
|
+
str[-1, 0] = " #{posstr} #{state}"
|
268
|
+
str
|
269
|
+
end
|
270
|
+
|
243
271
|
private
|
244
272
|
def start
|
245
273
|
@request_method = @buffer.scan(/[a-z]+ /i)
|
@@ -254,7 +282,7 @@ module HTTPTools
|
|
254
282
|
elsif @allow_html_without_header && @buffer.check(/\s*</i)
|
255
283
|
skip_header
|
256
284
|
else
|
257
|
-
raise ParseError.new("Protocol or method not recognised")
|
285
|
+
raise ParseError.new("Protocol or method not recognised at " + posstr)
|
258
286
|
end
|
259
287
|
end
|
260
288
|
|
@@ -269,7 +297,7 @@ module HTTPTools
|
|
269
297
|
elsif @buffer.check(/[a-z0-9;\/?:@&=+$,%_.!~*')(#-]+\Z/i)
|
270
298
|
:uri
|
271
299
|
else
|
272
|
-
raise ParseError.new("URI or path not recognised")
|
300
|
+
raise ParseError.new("URI or path not recognised at " + posstr)
|
273
301
|
end
|
274
302
|
end
|
275
303
|
|
@@ -285,7 +313,7 @@ module HTTPTools
|
|
285
313
|
@buffer.check(/ (H(T(T(P(\/(\d+(\.(\d+\r?)?)?)?)?)?)?)?)?\Z/i)
|
286
314
|
:request_http_version
|
287
315
|
else
|
288
|
-
raise ParseError.new("Invalid version specifier")
|
316
|
+
raise ParseError.new("Invalid version specifier at " + posstr)
|
289
317
|
end
|
290
318
|
end
|
291
319
|
|
@@ -299,7 +327,7 @@ module HTTPTools
|
|
299
327
|
@buffer.check(/H(T(T(P(\/(\d+(\.(\d+\r?)?)?)?)?)?)?)?\Z/i)
|
300
328
|
:response_http_version
|
301
329
|
else
|
302
|
-
raise ParseError.new("Invalid version specifier")
|
330
|
+
raise ParseError.new("Invalid version specifier at " + posstr)
|
303
331
|
end
|
304
332
|
end
|
305
333
|
|
@@ -309,7 +337,7 @@ module HTTPTools
|
|
309
337
|
@message = ""
|
310
338
|
@header_complete = true
|
311
339
|
@header_callback.call if @header_callback
|
312
|
-
|
340
|
+
start_body
|
313
341
|
end
|
314
342
|
|
315
343
|
def status
|
@@ -322,7 +350,7 @@ module HTTPTools
|
|
322
350
|
@buffer.check(/\d(\d(\d( ([^\x00-\x1f\x7f]+\r?)?)?)?)?\Z/i)
|
323
351
|
:status
|
324
352
|
else
|
325
|
-
raise ParseError.new("Invalid status line")
|
353
|
+
raise ParseError.new("Invalid status line at " + posstr)
|
326
354
|
end
|
327
355
|
end
|
328
356
|
|
@@ -334,7 +362,7 @@ module HTTPTools
|
|
334
362
|
elsif @buffer.skip(/\r?\n/i)
|
335
363
|
@header_complete = true
|
336
364
|
@header_callback.call if @header_callback
|
337
|
-
|
365
|
+
start_body
|
338
366
|
elsif @buffer.eos? || @buffer.check(/([ -9;-~]+:?|\r)\Z/i)
|
339
367
|
:key_or_newline
|
340
368
|
elsif @last_key = @buffer.scan(/[ -9;-~]+:(?=[^ ])/i)
|
@@ -351,7 +379,7 @@ module HTTPTools
|
|
351
379
|
elsif @buffer.check(/[^\x00\n\x7f]+\Z/)
|
352
380
|
:skip_bad_header
|
353
381
|
else
|
354
|
-
raise ParseError.new("Illegal character in field name")
|
382
|
+
raise ParseError.new("Illegal character in field name at " + posstr)
|
355
383
|
end
|
356
384
|
end
|
357
385
|
|
@@ -359,51 +387,58 @@ module HTTPTools
|
|
359
387
|
value = @buffer.scan(/[^\x00\n\x7f]*\n/i)
|
360
388
|
if value
|
361
389
|
value.chop!
|
362
|
-
if
|
363
|
-
@header
|
390
|
+
if @header.key?(@last_key)
|
391
|
+
@header[@last_key] << "\n#{value}"
|
364
392
|
else
|
365
393
|
@header[@last_key] = value
|
366
394
|
end
|
395
|
+
if CONTENT_LENGTH.casecmp(@last_key) == 0
|
396
|
+
@content_left = value.to_i
|
397
|
+
elsif TRANSFER_ENCODING.casecmp(@last_key) == 0
|
398
|
+
@chunked = CHUNKED.casecmp(value) == 0
|
399
|
+
end
|
367
400
|
key_or_newline
|
368
401
|
elsif @buffer.eos? || @buffer.check(/[^\x00\n\x7f]+\Z/i)
|
369
402
|
:value
|
370
403
|
else
|
371
|
-
raise ParseError.new("Illegal character in field body")
|
404
|
+
raise ParseError.new("Illegal character in field body at " + posstr)
|
372
405
|
end
|
373
406
|
end
|
374
407
|
|
375
|
-
def
|
376
|
-
if @request_method &&
|
377
|
-
!(@header.key?(CONTENT_LENGTH) || @header.key?(TRANSFER_ENCODING)) ||
|
408
|
+
def start_body
|
409
|
+
if @request_method && !(@content_left || @chunked) ||
|
378
410
|
NO_BODY.key?(@status_code) || @force_no_body
|
379
411
|
end_of_message
|
412
|
+
elsif @content_left
|
413
|
+
@buffer = [@buffer.rest]
|
414
|
+
body_with_length
|
415
|
+
elsif @chunked
|
416
|
+
@trailer_expected = @header.any? {|k,v| TRAILER.casecmp(k) == 0}
|
417
|
+
body_chunked
|
380
418
|
else
|
381
|
-
|
382
|
-
|
383
|
-
@content_left = length.to_i
|
384
|
-
body_with_length
|
385
|
-
elsif @header[TRANSFER_ENCODING] == CHUNKED
|
386
|
-
body_chunked
|
387
|
-
else
|
388
|
-
body_on_close
|
389
|
-
end
|
419
|
+
@buffer = [@buffer.rest]
|
420
|
+
body_on_close
|
390
421
|
end
|
391
422
|
end
|
392
423
|
|
393
424
|
def body_with_length
|
394
|
-
|
395
|
-
|
396
|
-
@stream_callback.call(chunk) if @stream_callback
|
425
|
+
chunk = @buffer.shift
|
426
|
+
if !chunk.empty?
|
397
427
|
chunk_length = chunk.length
|
398
|
-
|
428
|
+
if chunk_length > @content_left
|
429
|
+
@scanner << chunk.slice!(@content_left..-1)
|
430
|
+
end
|
431
|
+
@stream_callback.call(chunk) if @stream_callback
|
399
432
|
@content_left -= chunk_length
|
400
433
|
if @content_left < 1
|
434
|
+
@buffer = @scanner
|
401
435
|
end_of_message
|
402
436
|
else
|
403
437
|
:body_with_length
|
404
438
|
end
|
405
439
|
elsif @content_left < 1 # zero length body
|
406
440
|
@stream_callback.call("") if @stream_callback
|
441
|
+
@buffer = @scanner
|
407
442
|
end_of_message
|
408
443
|
else
|
409
444
|
:body_with_length
|
@@ -411,24 +446,38 @@ module HTTPTools
|
|
411
446
|
end
|
412
447
|
|
413
448
|
def body_chunked
|
414
|
-
|
415
|
-
|
416
|
-
@
|
417
|
-
|
418
|
-
|
419
|
-
|
420
|
-
|
421
|
-
|
422
|
-
|
423
|
-
|
424
|
-
|
449
|
+
while true
|
450
|
+
start_pos = @buffer.pos
|
451
|
+
hex_chunk_length = @buffer.scan(/[0-9a-f]+ *\r?\n/i)
|
452
|
+
break :body_chunked unless hex_chunk_length
|
453
|
+
|
454
|
+
chunk_length = hex_chunk_length.to_i(16)
|
455
|
+
if chunk_length == 0
|
456
|
+
if @trailer_expected || @force_trailer
|
457
|
+
break trailer_key_or_newline
|
458
|
+
else
|
459
|
+
break end_of_message
|
460
|
+
end
|
461
|
+
end
|
462
|
+
|
463
|
+
begin
|
464
|
+
chunk = @buffer.rest.slice(0, chunk_length)
|
465
|
+
@buffer.pos += chunk_length
|
466
|
+
if chunk && @buffer.skip(/\r?\n/i)
|
467
|
+
@stream_callback.call(chunk) if @stream_callback
|
468
|
+
else
|
469
|
+
@buffer.pos = start_pos
|
470
|
+
break :body_chunked
|
471
|
+
end
|
472
|
+
rescue RangeError
|
473
|
+
@buffer.pos = start_pos
|
474
|
+
break :body_chunked
|
425
475
|
end
|
426
476
|
end
|
427
477
|
end
|
428
478
|
|
429
479
|
def body_on_close
|
430
|
-
chunk = @buffer.
|
431
|
-
@buffer.terminate
|
480
|
+
chunk = @buffer.shift
|
432
481
|
@stream_callback.call(chunk) if @stream_callback
|
433
482
|
:body_on_close
|
434
483
|
end
|
@@ -446,7 +495,7 @@ module HTTPTools
|
|
446
495
|
@last_key.chomp!(COLON)
|
447
496
|
trailer_value
|
448
497
|
else
|
449
|
-
raise ParseError.new("Illegal character in field name")
|
498
|
+
raise ParseError.new("Illegal character in field name at " + posstr)
|
450
499
|
end
|
451
500
|
end
|
452
501
|
|
@@ -454,12 +503,16 @@ module HTTPTools
|
|
454
503
|
value = @buffer.scan(/[^\000\n\177]+\n/i)
|
455
504
|
if value
|
456
505
|
value.chop!
|
457
|
-
@trailer
|
506
|
+
if @trailer.key?(@last_key)
|
507
|
+
@trailer[@last_key] << "\n#{value}"
|
508
|
+
else
|
509
|
+
@trailer[@last_key] = value
|
510
|
+
end
|
458
511
|
trailer_key_or_newline
|
459
512
|
elsif @buffer.eos? || @buffer.check(/[^\x00\n\x7f]+\Z/i)
|
460
513
|
:trailer_value
|
461
514
|
else
|
462
|
-
raise ParseError.new("Illegal character in field body")
|
515
|
+
raise ParseError.new("Illegal character in field body at " + posstr)
|
463
516
|
end
|
464
517
|
end
|
465
518
|
|
@@ -477,5 +530,31 @@ module HTTPTools
|
|
477
530
|
end
|
478
531
|
alias error raise
|
479
532
|
|
533
|
+
def setup_stream_callback(chunk)
|
534
|
+
@body = ""
|
535
|
+
stream_callback(chunk)
|
536
|
+
@stream_callback = method(:stream_callback)
|
537
|
+
end
|
538
|
+
|
539
|
+
def stream_callback(chunk)
|
540
|
+
@body << chunk
|
541
|
+
end
|
542
|
+
|
543
|
+
def line_char(string, position)
|
544
|
+
line_count = 1
|
545
|
+
char_count = 0
|
546
|
+
string.each_line do |line|
|
547
|
+
break if line.length + char_count > position
|
548
|
+
line_count += 1
|
549
|
+
char_count += line.length
|
550
|
+
end
|
551
|
+
[line_count, position + 1 - char_count]
|
552
|
+
end
|
553
|
+
|
554
|
+
def posstr
|
555
|
+
line, char = line_char(@buffer.string, @buffer.pos)
|
556
|
+
"line #{line}, char #{char}"
|
557
|
+
end
|
558
|
+
|
480
559
|
end
|
481
|
-
end
|
560
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
base = File.expand_path(File.dirname(__FILE__) + '/../../lib')
|
2
|
+
require base + '/http_tools'
|
3
|
+
require 'rubygems'
|
4
|
+
require 'ruby-prof'
|
5
|
+
|
6
|
+
body = "x" * 1024 * 1024 * 1
|
7
|
+
response = "HTTP/1.1 200 OK\r\nDate: Mon, 06 Jun 2011 14:55:51 GMT\r\nServer: Apache/2.2.17 (Unix) mod_ssl/2.2.17 OpenSSL/0.9.8l DAV/2 mod_fastcgi/2.4.2\r\nLast-Modified: Mon, 06 Jun 2011 14:55:49 GMT\r\nETag: \"3f18045-400-4a50c4c87c740\"\r\nAccept-Ranges: bytes\r\nContent-Length: #{body.length}\r\nContent-Type: text/plain\r\n\r\n"
|
8
|
+
chunks = []
|
9
|
+
64.times {|i| chunks << body[i * 64, body.length / 64]}
|
10
|
+
parser = HTTPTools::Parser.new
|
11
|
+
|
12
|
+
result = RubyProf.profile do
|
13
|
+
parser << response
|
14
|
+
chunks.each {|chunk| parser << chunk}
|
15
|
+
end
|
16
|
+
RubyProf::FlatPrinter.new(result).print(STDOUT, 0)
|
@@ -3,7 +3,7 @@ require base + '/http_tools'
|
|
3
3
|
require 'test/unit'
|
4
4
|
require 'uri'
|
5
5
|
|
6
|
-
class
|
6
|
+
class BuilderRequestTest < Test::Unit::TestCase
|
7
7
|
|
8
8
|
def test_get
|
9
9
|
result = HTTPTools::Builder.request(:get, "www.example.com", "/test")
|
@@ -18,9 +18,27 @@ class RequestTest < Test::Unit::TestCase
|
|
18
18
|
end
|
19
19
|
|
20
20
|
def test_headers
|
21
|
-
result = HTTPTools::Builder.request(:get, "www.foobar.com", "/", "
|
21
|
+
result = HTTPTools::Builder.request(:get, "www.foobar.com", "/", "X-Test" => "foo")
|
22
22
|
|
23
|
-
assert_equal("GET / HTTP/1.1\r\nHost: www.foobar.com\r\
|
23
|
+
assert_equal("GET / HTTP/1.1\r\nHost: www.foobar.com\r\nX-Test: foo\r\n\r\n", result)
|
24
|
+
end
|
25
|
+
|
26
|
+
def test_newline_separated_multi_value_headers
|
27
|
+
result = HTTPTools::Builder.request(:get, "www.foo.com", "/", "X-Test" => "foo\nbar")
|
28
|
+
|
29
|
+
assert_equal("GET / HTTP/1.1\r\nHost: www.foo.com\r\nX-Test: foo\r\nX-Test: bar\r\n\r\n", result)
|
30
|
+
end
|
31
|
+
|
32
|
+
def test_array_multi_value_headers
|
33
|
+
result = HTTPTools::Builder.request(:get, "www.foo.com", "/", "X-Test" => ["foo", "bar"])
|
34
|
+
|
35
|
+
assert_equal("GET / HTTP/1.1\r\nHost: www.foo.com\r\nX-Test: foo\r\nX-Test: bar\r\n\r\n", result)
|
36
|
+
end
|
37
|
+
|
38
|
+
def test_non_string_headers
|
39
|
+
result = HTTPTools::Builder.request(:get, "www.foobar.com", "/", "X-Test" => 42)
|
40
|
+
|
41
|
+
assert_equal("GET / HTTP/1.1\r\nHost: www.foobar.com\r\nX-Test: 42\r\n\r\n", result)
|
24
42
|
end
|
25
43
|
|
26
44
|
end
|