http_tools 0.1.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 +80 -0
- data/bench/parser/request_bench.rb +59 -0
- data/bench/parser/response_bench.rb +21 -0
- data/example/http_client.rb +132 -0
- data/lib/http_tools.rb +110 -0
- data/lib/http_tools/builder.rb +49 -0
- data/lib/http_tools/encoding.rb +169 -0
- data/lib/http_tools/errors.rb +5 -0
- data/lib/http_tools/parser.rb +478 -0
- data/profile/parser/request_profile.rb +12 -0
- data/profile/parser/response_profile.rb +12 -0
- data/test/builder/request_test.rb +26 -0
- data/test/builder/response_test.rb +32 -0
- data/test/cover.rb +28 -0
- data/test/encoding/transfer_encoding_chunked_test.rb +141 -0
- data/test/encoding/url_encoding_test.rb +37 -0
- data/test/encoding/www_form_test.rb +42 -0
- data/test/parser/request_test.rb +481 -0
- data/test/parser/response_test.rb +446 -0
- data/test/runner.rb +1 -0
- metadata +89 -0
@@ -0,0 +1,169 @@
|
|
1
|
+
require 'strscan'
|
2
|
+
|
3
|
+
module HTTPTools
|
4
|
+
|
5
|
+
# HTTPTools::Encoding provides methods to deal with several HTTP related
|
6
|
+
# encodings including url, www-form, and chunked transfer encoding. It can be
|
7
|
+
# used as a mixin or class methods on HTTPTools::Encoding.
|
8
|
+
#
|
9
|
+
module Encoding
|
10
|
+
HEX_BIG_ENDIAN_2_BYTES = "H2".freeze
|
11
|
+
HEX_BIG_ENDIAN_REPEATING = "H*".freeze
|
12
|
+
PERCENT = "%".freeze
|
13
|
+
PLUS = "+".freeze
|
14
|
+
AMPERSAND = "&".freeze
|
15
|
+
EQUALS = "=".freeze
|
16
|
+
CHUNK_FORMAT = "%x\r\n%s\r\n".freeze
|
17
|
+
|
18
|
+
module_function
|
19
|
+
|
20
|
+
# :call-seq: Encoding.url_encode(string) -> encoded_string
|
21
|
+
#
|
22
|
+
# URL encode a string, eg "le café" becomes "le+caf%c3%a9"
|
23
|
+
#
|
24
|
+
def url_encode(string)
|
25
|
+
string.gsub(/[^a-zA-Z0-9._~-]+/) do |match|
|
26
|
+
length = match.respond_to?(:bytesize) ? match.bytesize : match.length
|
27
|
+
PERCENT + match.unpack(HEX_BIG_ENDIAN_2_BYTES * length).join(PERCENT)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
# :call-seq: Encoding.url_decode(encoded_string) -> string
|
32
|
+
#
|
33
|
+
# URL decode a string, eg "le+caf%c3%a9" becomes "le café"
|
34
|
+
#
|
35
|
+
def url_decode(string)
|
36
|
+
string.tr(PLUS, SPACE).gsub(/(%[0-9a-fA-F]{2})+/) do |match|
|
37
|
+
r = [match.delete(PERCENT)].pack(HEX_BIG_ENDIAN_REPEATING)
|
38
|
+
r.respond_to?(:force_encoding) ? r.force_encoding(string.encoding) : r
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
# :call-seq: Encoding.www_form_encode(hash) -> string
|
43
|
+
#
|
44
|
+
# Takes a Hash and converts it to a String as if it was a HTML form being
|
45
|
+
# submitted, eg
|
46
|
+
# {"query" => "fish", "lang" => "en"} becomes "lang=en&query=fish"
|
47
|
+
#
|
48
|
+
# To get multiple key value pairs with the same key use an array as the
|
49
|
+
# value, eg
|
50
|
+
# {"lang" => ["en", "fr"]} become "lang=en&lang=fr"
|
51
|
+
#
|
52
|
+
def www_form_encode(hash)
|
53
|
+
hash.map do |key, value|
|
54
|
+
if value.respond_to?(:map) && !value.is_a?(String)
|
55
|
+
value.map {|val| www_form_encode(key => val.to_s)}.join(AMPERSAND)
|
56
|
+
else
|
57
|
+
url_encode(key.to_s) << EQUALS << url_encode(value.to_s)
|
58
|
+
end
|
59
|
+
end.join(AMPERSAND)
|
60
|
+
end
|
61
|
+
|
62
|
+
# :call-seq: Encoding.www_form_decode(string) -> hash
|
63
|
+
#
|
64
|
+
# Takes a String resulting from a HTML form being submitted, and converts it
|
65
|
+
# to a hash,
|
66
|
+
# eg "lang=en&query=fish" becomes {"query" => "fish", "lang" => "en"}
|
67
|
+
#
|
68
|
+
# Multiple key value pairs with the same key will become a single key with
|
69
|
+
# an array value, eg "lang=en&lang=fr" becomes {"lang" => ["en", "fr"]}
|
70
|
+
#
|
71
|
+
def www_form_decode(string)
|
72
|
+
string.split(AMPERSAND).inject({}) do |memo, key_value|
|
73
|
+
key, value = key_value.split(EQUALS)
|
74
|
+
key, value = url_decode(key), url_decode(value)
|
75
|
+
if memo.key?(key)
|
76
|
+
memo.merge(key => [*memo[key]].push(value))
|
77
|
+
else
|
78
|
+
memo.merge(key => value)
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
# :call-seq:
|
84
|
+
# Encoding.transfer_encoding_chunked_encode(string) -> encoded_string
|
85
|
+
#
|
86
|
+
# Returns string as a 'chunked' transfer encoding encoded string, suitable
|
87
|
+
# for a streaming response from a HTTP server, eg
|
88
|
+
# "foo" becomes "3\r\nfoo\r\n"
|
89
|
+
#
|
90
|
+
# chunked responses should be terminted with a empty chunk, eg "0\r\n",
|
91
|
+
# passing an empty string or nil will generate the empty chunk.
|
92
|
+
#
|
93
|
+
def transfer_encoding_chunked_encode(string)
|
94
|
+
if string && string.length > 0
|
95
|
+
sprintf(CHUNK_FORMAT, string.length, string)
|
96
|
+
else
|
97
|
+
"0\r\n"
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
# :call-seq:
|
102
|
+
# Encoding.transfer_encoding_chunked_decode(encoded_string) -> array
|
103
|
+
#
|
104
|
+
# Decoding a complete chunked response will return an array containing
|
105
|
+
# the decoded response and nil.
|
106
|
+
# Example:
|
107
|
+
# encoded_string = "3\r\nfoo\r\n3\r\nbar\r\n0\r\n"
|
108
|
+
# Encoding.transfer_encoding_chunked_decode(encoded_string)\
|
109
|
+
# => ["foobar", nil]
|
110
|
+
#
|
111
|
+
# Decoding a partial response will return an array of the response decoded
|
112
|
+
# so far, and the remainder of the encoded string.
|
113
|
+
# Example
|
114
|
+
# encoded_string = "3\r\nfoo\r\n3\r\nba"
|
115
|
+
# Encoding.transfer_encoding_chunked_decode(encoded_string)\
|
116
|
+
# => ["foo", "3\r\nba"]
|
117
|
+
#
|
118
|
+
# If the chunks are complete, but there is no empty terminating chunk, the
|
119
|
+
# second element in the array will be an empty string.
|
120
|
+
# encoded_string = "3\r\nfoo\r\n3\r\nbar"
|
121
|
+
# Encoding.transfer_encoding_chunked_decode(encoded_string)\
|
122
|
+
# => ["foobar", ""]
|
123
|
+
#
|
124
|
+
# If nothing can be decoded the first element in the array will be an empty
|
125
|
+
# string and the second the remainder
|
126
|
+
# encoded_string = "3\r\nfo"
|
127
|
+
# Encoding.transfer_encoding_chunked_decode(encoded_string)\
|
128
|
+
# => ["", "3\r\nfo"]
|
129
|
+
#
|
130
|
+
# Example use:
|
131
|
+
# include Encoding
|
132
|
+
# decoded = ""
|
133
|
+
# remainder = ""
|
134
|
+
# while remainder
|
135
|
+
# remainder << get_data
|
136
|
+
# chunk, remainder = transfer_encoding_chunked_decode(remainder)
|
137
|
+
# decoded << chunk
|
138
|
+
# end
|
139
|
+
#
|
140
|
+
def transfer_encoding_chunked_decode(scanner)
|
141
|
+
unless scanner.is_a?(StringScanner)
|
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
|
149
|
+
|
150
|
+
chunk = scanner.rest.slice(0, chunk_length)
|
151
|
+
begin
|
152
|
+
scanner.pos += chunk_length
|
153
|
+
separator = scanner.scan(/\n|\r\n/)
|
154
|
+
rescue RangeError
|
155
|
+
end
|
156
|
+
|
157
|
+
if separator && chunk.length == chunk_length
|
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
|
+
end
|
167
|
+
|
168
|
+
end
|
169
|
+
end
|
@@ -0,0 +1,478 @@
|
|
1
|
+
require 'strscan'
|
2
|
+
|
3
|
+
module HTTPTools
|
4
|
+
|
5
|
+
# HTTPTools::Parser is a pure Ruby HTTP request & response parser with an
|
6
|
+
# evented API.
|
7
|
+
#
|
8
|
+
# The HTTP message can be fed in to the parser piece by piece as it comes over
|
9
|
+
# the wire, and the parser will call its callbacks as it works it's way
|
10
|
+
# through the message.
|
11
|
+
#
|
12
|
+
# Example:
|
13
|
+
# parser = HTTPTools::Parser.new
|
14
|
+
# parser.on(:status) {|status, message| puts "#{status} #{message}"}
|
15
|
+
# parser.on(:headers) {|headers| puts headers.inspect}
|
16
|
+
# parser.on(:body) {|body| puts body}
|
17
|
+
#
|
18
|
+
# parser << "HTTP/1.1 200 OK\r\n"
|
19
|
+
# parser << "Content-Length: 20\r\n\r\n"
|
20
|
+
# parser << "<h1>Hello world</h1>"
|
21
|
+
#
|
22
|
+
# Prints:
|
23
|
+
# 200 OK
|
24
|
+
# {"Content-Length" => "20"}
|
25
|
+
# <h1>Hello world</h1>
|
26
|
+
#
|
27
|
+
class Parser
|
28
|
+
include Encoding
|
29
|
+
|
30
|
+
KEY_TERMINATOR = ": ".freeze
|
31
|
+
CONTENT_LENGTH = "Content-Length".freeze
|
32
|
+
TRANSFER_ENCODING = "Transfer-Encoding".freeze
|
33
|
+
TRAILER = "Trailer".freeze
|
34
|
+
CHUNKED = "chunked".freeze
|
35
|
+
EVENTS = ["method", "path", "uri", "fragment", "version", "status", "key",
|
36
|
+
"value", "headers", "stream", "body", "trailers", "finished",
|
37
|
+
"error"].map {|event| event.freeze}.freeze
|
38
|
+
|
39
|
+
attr_reader :state # :nodoc:
|
40
|
+
|
41
|
+
# Force parser to expect and parse a trailer when Trailer header missing.
|
42
|
+
attr_accessor :force_trailer
|
43
|
+
|
44
|
+
# Skip parsing the body, e.g. with the response to a HEAD request.
|
45
|
+
attr_accessor :force_no_body
|
46
|
+
|
47
|
+
# :call-seq: Parser.new(delegate=nil) -> parser
|
48
|
+
#
|
49
|
+
# Create a new HTTPTools::Parser.
|
50
|
+
#
|
51
|
+
# delegate is an object that will recieve callbacks for events during
|
52
|
+
# parsing. The delegate's methods should be named on_[event name], e.g.
|
53
|
+
# on_status, on_body, etc. See #add_listener for more.
|
54
|
+
#
|
55
|
+
# Example:
|
56
|
+
# class ExampleDelegate
|
57
|
+
# def on_status(status, message)
|
58
|
+
# puts "#{status} #{message}"
|
59
|
+
# end
|
60
|
+
# end
|
61
|
+
# parser = HTTPTools::Parser.new(ExampleDelegate.new)
|
62
|
+
#
|
63
|
+
# If a callback is set for an event, it will take precedence over the
|
64
|
+
# delegate for that event.
|
65
|
+
#
|
66
|
+
def initialize(delegate=nil)
|
67
|
+
@state = :start
|
68
|
+
@buffer = StringScanner.new("")
|
69
|
+
@buffer_backup_reference = @buffer
|
70
|
+
@status = nil
|
71
|
+
@headers = {}
|
72
|
+
@last_key = nil
|
73
|
+
@content_left = nil
|
74
|
+
@body = nil
|
75
|
+
if delegate
|
76
|
+
EVENTS.each do |event|
|
77
|
+
id = "on_#{event}"
|
78
|
+
add_listener(event, delegate.method(id)) if delegate.respond_to?(id)
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
# :call-seq: parser.concat(data) -> parser
|
84
|
+
# parser << data -> parser
|
85
|
+
#
|
86
|
+
# Feed data in to the parser and trigger callbacks.
|
87
|
+
#
|
88
|
+
# Will raise HTTPTools::ParseError on error, unless a callback has been set
|
89
|
+
# for the :error event, in which case the callback will recieve the error
|
90
|
+
# insted.
|
91
|
+
#
|
92
|
+
def concat(data)
|
93
|
+
@buffer << data
|
94
|
+
@state = send(@state)
|
95
|
+
self
|
96
|
+
end
|
97
|
+
alias << concat
|
98
|
+
|
99
|
+
# :call-seq: parser.finish -> parser
|
100
|
+
#
|
101
|
+
# Used to notify the parser that the request has finished in a case where it
|
102
|
+
# can not be determined by the request itself.
|
103
|
+
#
|
104
|
+
# For example, when a server does not set a content length, and instead
|
105
|
+
# relies on closing the connection to signify the body end.
|
106
|
+
# until parser.finished?
|
107
|
+
# begin
|
108
|
+
# parser << socket.sysread(1024 * 16)
|
109
|
+
# rescue EOFError
|
110
|
+
# parser.finish
|
111
|
+
# break
|
112
|
+
# end
|
113
|
+
# end
|
114
|
+
#
|
115
|
+
# This method can not be used to interrupt parsing from within a callback.
|
116
|
+
#
|
117
|
+
# Will raise HTTPTools::MessageIncompleteError if called too early, or
|
118
|
+
# HTTPTools::EndOfMessageError if the message has already finished, unless
|
119
|
+
# a callback has been set for the :error event, in which case the callback
|
120
|
+
# will recieve the error insted.
|
121
|
+
#
|
122
|
+
def finish
|
123
|
+
if @state == :body_on_close
|
124
|
+
@body_callback.call(@body) if @body_callback
|
125
|
+
@state = end_of_message
|
126
|
+
else
|
127
|
+
raise MessageIncompleteError.new("Message ended early")
|
128
|
+
end
|
129
|
+
self
|
130
|
+
end
|
131
|
+
|
132
|
+
# :call-seq: parser.finished? -> bool
|
133
|
+
#
|
134
|
+
# Returns true when the parser has come to the end of the message, false
|
135
|
+
# otherwise.
|
136
|
+
#
|
137
|
+
# Some HTTP servers may not supply the necessary information in the response
|
138
|
+
# to determine the end of the message (e.g., no content length) and insted
|
139
|
+
# close the connection to signify the end of the message, see #finish for
|
140
|
+
# how to deal with this.
|
141
|
+
#
|
142
|
+
def finished?
|
143
|
+
@state == :end_of_message
|
144
|
+
end
|
145
|
+
|
146
|
+
# :call-seq: parser.reset -> parser
|
147
|
+
#
|
148
|
+
# Reset the parser so it can be used to process a new request.
|
149
|
+
# Callbacks/delegates will not be removed.
|
150
|
+
#
|
151
|
+
def reset
|
152
|
+
@state = :start
|
153
|
+
@buffer = @buffer_backup_reference
|
154
|
+
@buffer.string.replace("")
|
155
|
+
@buffer.reset
|
156
|
+
# @status = nil
|
157
|
+
@headers = {}
|
158
|
+
@trailer = {}
|
159
|
+
# @last_key = nil
|
160
|
+
# @content_left = nil
|
161
|
+
@body = nil
|
162
|
+
self
|
163
|
+
end
|
164
|
+
|
165
|
+
# :call-seq: parser.add_listener(event) {|arg1 [, arg2]| block} -> parser
|
166
|
+
# parser.add_listener(event, proc) -> parser
|
167
|
+
# parser.on(event) {|arg1 [, arg2]| block} -> parser
|
168
|
+
# parser.on(event, proc) -> parser
|
169
|
+
#
|
170
|
+
# Available events are :method, :path, :version, :status, :headers, :stream,
|
171
|
+
# :body, and :error.
|
172
|
+
#
|
173
|
+
# Adding a second callback for an event will overwite the existing callback
|
174
|
+
# or delegate.
|
175
|
+
#
|
176
|
+
# Events:
|
177
|
+
# [method] Supplied with one argument, the HTTP method as a String,
|
178
|
+
# e.g. "GET"
|
179
|
+
#
|
180
|
+
# [path] Supplied with two arguments, the request path as a String,
|
181
|
+
# e.g. "/example.html", and the query string as a String,
|
182
|
+
# e.g. "query=foo"
|
183
|
+
# (this callback is only called if the request uri is a path)
|
184
|
+
#
|
185
|
+
# [uri] Supplied with one argument, the request uri as a String,
|
186
|
+
# e.g. "/example.html?query=foo"
|
187
|
+
#
|
188
|
+
# [fragment] Supplied with one argument, the fragment from the request
|
189
|
+
# uri, if present
|
190
|
+
#
|
191
|
+
# [version] Supplied with one argument, the HTTP version as a String,
|
192
|
+
# e.g. "1.1"
|
193
|
+
#
|
194
|
+
# [status] Supplied with two arguments, the HTTP status code as a
|
195
|
+
# Numeric, e.g. 200, and the HTTP status message as a String,
|
196
|
+
# e.g. "OK"
|
197
|
+
#
|
198
|
+
# [headers] Supplied with one argument, the message headers as a Hash,
|
199
|
+
# e.g. {"Content-Length" => "20"}
|
200
|
+
#
|
201
|
+
# [stream] Supplied with one argument, the last chunk of body data fed
|
202
|
+
# in to the parser as a String, e.g. "<h1>Hello"
|
203
|
+
#
|
204
|
+
# [body] Supplied with one argument, the message body as a String,
|
205
|
+
# e.g. "<h1>Hello world</h1>"
|
206
|
+
#
|
207
|
+
# [finished] Supplied with one argument, any data left in the parser's
|
208
|
+
# buffer after the end of the HTTP message (likely nil, but
|
209
|
+
# possibly the start of the next message)
|
210
|
+
#
|
211
|
+
# [error] Supplied with one argument, an error encountered while
|
212
|
+
# parsing as a HTTPTools::ParseError. If a listener isn't
|
213
|
+
# registered for this event, an exception will be raised when
|
214
|
+
# an error is encountered
|
215
|
+
#
|
216
|
+
def add_listener(event, proc=nil, &block)
|
217
|
+
instance_variable_set(:"@#{event}_callback", proc || block)
|
218
|
+
self
|
219
|
+
end
|
220
|
+
alias on add_listener
|
221
|
+
|
222
|
+
private
|
223
|
+
def start
|
224
|
+
method = @buffer.scan(/[a-z]+ /i)
|
225
|
+
if method
|
226
|
+
if @method_callback
|
227
|
+
method.chop!
|
228
|
+
method.upcase!
|
229
|
+
@method_callback.call(method)
|
230
|
+
end
|
231
|
+
uri
|
232
|
+
elsif @buffer.skip(/HTTP\//i)
|
233
|
+
response_http_version
|
234
|
+
elsif @buffer.check(/[a-z]+\Z/i)
|
235
|
+
:start
|
236
|
+
else
|
237
|
+
raise ParseError.new("Protocol or method not recognised")
|
238
|
+
end
|
239
|
+
end
|
240
|
+
|
241
|
+
def uri
|
242
|
+
uri = @buffer.scan(/[a-z0-9;\/?:@&=+$,%_.!~*')(#-]*(?=( |\r\n))/i)
|
243
|
+
if uri
|
244
|
+
fragment = uri.slice!(/#[a-z0-9;\/?:@&=+$,%_.!~*')(-]+\Z/i)
|
245
|
+
if @path_callback && uri =~ /^\//i
|
246
|
+
path = uri.dup
|
247
|
+
query = path.slice!(/\?[a-z0-9;\/?:@&=+$,%_.!~*')(-]*/i)
|
248
|
+
query.slice!(0) if query
|
249
|
+
@path_callback.call(path, query)
|
250
|
+
end
|
251
|
+
@uri_callback.call(uri) if @uri_callback
|
252
|
+
if fragment && @fragment_callback
|
253
|
+
fragment.slice!(0)
|
254
|
+
@fragment_callback.call(fragment)
|
255
|
+
end
|
256
|
+
space_before_http
|
257
|
+
elsif @buffer.check(/[a-z0-9;\/?:@&=+$,%_.!~*')(#-]+\Z/i)
|
258
|
+
:uri
|
259
|
+
else
|
260
|
+
raise ParseError.new("URI or path not recognised")
|
261
|
+
end
|
262
|
+
end
|
263
|
+
|
264
|
+
def space_before_http
|
265
|
+
if @buffer.skip(/ /i)
|
266
|
+
http
|
267
|
+
elsif @buffer.skip(/\r\n/i)
|
268
|
+
key_or_newline
|
269
|
+
end
|
270
|
+
end
|
271
|
+
|
272
|
+
def http
|
273
|
+
if @buffer.skip(/HTTP\//i)
|
274
|
+
request_http_version
|
275
|
+
elsif @buffer.eos? || @buffer.check(/H(T(T(P\/?)?)?)?\Z/i)
|
276
|
+
:http
|
277
|
+
else
|
278
|
+
raise ParseError.new("Protocol not recognised")
|
279
|
+
end
|
280
|
+
end
|
281
|
+
|
282
|
+
def request_http_version
|
283
|
+
version = @buffer.scan(/[0-9]+\.[0-9]+\r\n/i)
|
284
|
+
if version
|
285
|
+
if @version_callback
|
286
|
+
version.chop!
|
287
|
+
@version_callback.call(version)
|
288
|
+
end
|
289
|
+
key_or_newline
|
290
|
+
elsif @buffer.eos? || @buffer.check(/\d+(\.(\d+\r?)?)?\Z/i)
|
291
|
+
:request_http_version
|
292
|
+
else
|
293
|
+
raise ParseError.new("Invalid version specifier")
|
294
|
+
end
|
295
|
+
end
|
296
|
+
|
297
|
+
def response_http_version
|
298
|
+
version = @buffer.scan(/[0-9]+\.[0-9]+ /i)
|
299
|
+
if version
|
300
|
+
if @version_callback
|
301
|
+
version.chop!
|
302
|
+
@version_callback.call(version)
|
303
|
+
end
|
304
|
+
status
|
305
|
+
elsif @buffer.eos? || @buffer.check(/\d+(\.(\d+)?)?\Z/i)
|
306
|
+
:response_http_version
|
307
|
+
else
|
308
|
+
raise ParseError.new("Invalid version specifier")
|
309
|
+
end
|
310
|
+
end
|
311
|
+
|
312
|
+
def status
|
313
|
+
status = @buffer.scan(/\d\d\d [a-z -]+\r?\n/i)
|
314
|
+
if status
|
315
|
+
@status = status.slice!(0, 3).to_i
|
316
|
+
@status_callback.call(@status, status.strip) if @status_callback
|
317
|
+
key_or_newline
|
318
|
+
elsif @buffer.eos? || @buffer.check(/\d(\d(\d( ([a-z]+\r?)?)?)?)?\Z/i)
|
319
|
+
:status
|
320
|
+
else
|
321
|
+
raise ParseError.new("Invalid status line")
|
322
|
+
end
|
323
|
+
end
|
324
|
+
|
325
|
+
def key_or_newline
|
326
|
+
@last_key = @buffer.scan(/[!-9;-~]+: /i)
|
327
|
+
if @last_key
|
328
|
+
@last_key.chomp!(KEY_TERMINATOR)
|
329
|
+
value
|
330
|
+
elsif @buffer.skip(/\n|\r\n/i)
|
331
|
+
@headers_callback.call(@headers) if @headers_callback
|
332
|
+
body
|
333
|
+
elsif @buffer.eos? || @buffer.check(/[!-9;-~]+:?\Z/i)
|
334
|
+
:key_or_newline
|
335
|
+
else
|
336
|
+
raise ParseError.new("Illegal character in field name")
|
337
|
+
end
|
338
|
+
end
|
339
|
+
|
340
|
+
def value
|
341
|
+
value = @buffer.scan(/[ -~]+\r?\n/i)
|
342
|
+
if value
|
343
|
+
value.chop!
|
344
|
+
@headers[@last_key] = value
|
345
|
+
key_or_newline
|
346
|
+
elsif @buffer.eos? || @buffer.check(/[ -~]+\Z/i)
|
347
|
+
:value
|
348
|
+
else
|
349
|
+
raise ParseError.new("Illegal character in field body")
|
350
|
+
end
|
351
|
+
end
|
352
|
+
|
353
|
+
def body
|
354
|
+
if @force_no_body || NO_BODY[@status]
|
355
|
+
end_of_message
|
356
|
+
elsif @buffer.eos?
|
357
|
+
:body
|
358
|
+
else
|
359
|
+
@body = "" if @body_callback
|
360
|
+
@buffer = @buffer.rest # Switch @buffer from StringScanner to String
|
361
|
+
length = @headers[CONTENT_LENGTH]
|
362
|
+
if length
|
363
|
+
@content_left = length.to_i
|
364
|
+
body_with_length
|
365
|
+
elsif @headers[TRANSFER_ENCODING] == CHUNKED
|
366
|
+
body_chunked
|
367
|
+
else
|
368
|
+
body_on_close
|
369
|
+
end
|
370
|
+
end
|
371
|
+
end
|
372
|
+
|
373
|
+
#--
|
374
|
+
# From this point on @buffer is a String, not a StringScanner.
|
375
|
+
# This is because 1. we don't need a StringScanner anymore, 2. if we
|
376
|
+
# switched to a diffrent instace variable we'd need a condition in #concat
|
377
|
+
# to feed the data in to the new instace variable, which would slow us down.
|
378
|
+
#++
|
379
|
+
|
380
|
+
def body_with_length
|
381
|
+
if @buffer.length > 0
|
382
|
+
chunk = @buffer.slice!(0, @content_left)
|
383
|
+
@stream_callback.call(chunk) if @stream_callback
|
384
|
+
@body << chunk if @body_callback
|
385
|
+
@content_left -= chunk.length
|
386
|
+
if @content_left < 1
|
387
|
+
@body_callback.call(@body) if @body_callback
|
388
|
+
end_of_message
|
389
|
+
else
|
390
|
+
:body_with_length
|
391
|
+
end
|
392
|
+
else
|
393
|
+
:body_with_length
|
394
|
+
end
|
395
|
+
end
|
396
|
+
|
397
|
+
def body_chunked
|
398
|
+
decoded, remainder = transfer_encoding_chunked_decode(@buffer)
|
399
|
+
if decoded
|
400
|
+
@stream_callback.call(decoded) if @stream_callback
|
401
|
+
@body << decoded if @body_callback
|
402
|
+
end
|
403
|
+
if remainder
|
404
|
+
@buffer = remainder
|
405
|
+
:body_chunked
|
406
|
+
else
|
407
|
+
@buffer.slice!(/.*0\r\n/m)
|
408
|
+
@body_callback.call(@body) if @body_callback
|
409
|
+
if @headers[TRAILER] || @force_trailer
|
410
|
+
@trailer = {}
|
411
|
+
# @buffer switches back to a StringScanner for the trailer.
|
412
|
+
@buffer_backup_reference.string.replace(@buffer)
|
413
|
+
@buffer_backup_reference.reset
|
414
|
+
@buffer = @buffer_backup_reference
|
415
|
+
trailer_key_or_newline
|
416
|
+
else
|
417
|
+
end_of_message
|
418
|
+
end
|
419
|
+
end
|
420
|
+
end
|
421
|
+
|
422
|
+
def body_on_close
|
423
|
+
@stream_callback.call(@buffer) if @stream_callback
|
424
|
+
@body << @buffer if @body_callback
|
425
|
+
@buffer = ""
|
426
|
+
:body_on_close
|
427
|
+
end
|
428
|
+
|
429
|
+
#--
|
430
|
+
# @buffer switches back to a StringScanner for the trailer.
|
431
|
+
#++
|
432
|
+
|
433
|
+
def trailer_key_or_newline
|
434
|
+
if @last_key = @buffer.scan(/[!-9;-~]+: /i)
|
435
|
+
@last_key.chomp!(KEY_TERMINATOR)
|
436
|
+
trailer_value
|
437
|
+
elsif @buffer.skip(/\n|\r\n/i)
|
438
|
+
@trailer_callback.call(@trailer) if @trailer_callback
|
439
|
+
end_of_message
|
440
|
+
elsif @buffer.eos? || @buffer.check(/[!-9;-~]+:?\Z/i)
|
441
|
+
:trailer_key_or_newline
|
442
|
+
else
|
443
|
+
raise ParseError.new("Illegal character in field name")
|
444
|
+
end
|
445
|
+
end
|
446
|
+
|
447
|
+
def trailer_value
|
448
|
+
value = @buffer.scan(/[ -~]+\r?\n/i)
|
449
|
+
if value
|
450
|
+
value.chop!
|
451
|
+
@trailer[@last_key] = value
|
452
|
+
trailer_key_or_newline
|
453
|
+
elsif @buffer.eos? || @buffer.check(/[ -~]+\Z/i)
|
454
|
+
:trailer_value
|
455
|
+
else
|
456
|
+
raise ParseError.new("Illegal character in field body")
|
457
|
+
end
|
458
|
+
end
|
459
|
+
|
460
|
+
def end_of_message
|
461
|
+
raise EndOfMessageError.new("Message ended") if @state == :end_of_message
|
462
|
+
remainder = @buffer.respond_to?(:rest) ? @buffer.rest : @buffer
|
463
|
+
if @finished_callback
|
464
|
+
@finished_callback.call((remainder if remainder.length > 0))
|
465
|
+
end
|
466
|
+
:end_of_message
|
467
|
+
end
|
468
|
+
|
469
|
+
def raise(*args)
|
470
|
+
@state = :error
|
471
|
+
super unless @error_callback
|
472
|
+
@error_callback.call(args.first)
|
473
|
+
:error
|
474
|
+
end
|
475
|
+
alias error raise
|
476
|
+
|
477
|
+
end
|
478
|
+
end
|